From 36b9d26b9dd75bd021a3abc7b5ef20736ec1cd61 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 02:54:47 -0400 Subject: [PATCH 01/23] [Fix] (DirectVulkan): match the 2_10_10_10 storage image view format to the texture's own --- .../DirectVulkan/Renderer/ProgramFactory.cpp | 12 ++++++++++-- MobileGL/MG_Test/SanityTest.cpp | 7 +++++-- .../Converters/MGToVk/TextureEnumConverter.cpp | 12 ++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index de5214fc..3269165c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -2094,7 +2094,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { case SpvImageFormatR11fG11fB10f: return VK_FORMAT_B10G11R11_UFLOAT_PACK32; case SpvImageFormatR16f: return VK_FORMAT_R16_SFLOAT; case SpvImageFormatRgba16: return VK_FORMAT_R16G16B16A16_UNORM; - case SpvImageFormatRgb10A2: return VK_FORMAT_A2R10G10B10_UNORM_PACK32; + // A2**B**10G10R10, matching MGToVk::ConvertTextureInternalFormatToVkFormat's RGB10A2. + // This value becomes the storage image VIEW's format while the image itself was created + // from the texture's internal format, so the two must name the same bit layout or the + // shader reads the texel through a different component order than the host wrote it. + // GL_RGB10_A2 with GL_UNSIGNED_INT_2_10_10_10_REV puts R in bits 0-9, G in 10-19, B in + // 20-29 and A in 30-31, which is Vulkan's A2B10G10R10; A2R10G10B10 transposes R and B. + // KHR-GL43.shader_image_load_store.basic-allFormats-store read back [2,1,0,3] for an + // rgb10_a2ui image stored as [0,1,2,3] while these two converters disagreed. + case SpvImageFormatRgb10A2: return VK_FORMAT_A2B10G10R10_UNORM_PACK32; case SpvImageFormatRg16: return VK_FORMAT_R16G16_UNORM; case SpvImageFormatRg8: return VK_FORMAT_R8G8_UNORM; case SpvImageFormatR16: return VK_FORMAT_R16_UNORM; @@ -2117,7 +2125,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { case SpvImageFormatRgba16ui: return VK_FORMAT_R16G16B16A16_UINT; case SpvImageFormatRgba8ui: return VK_FORMAT_R8G8B8A8_UINT; case SpvImageFormatR32ui: return VK_FORMAT_R32_UINT; - case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2R10G10B10_UINT_PACK32; + case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2B10G10R10_UINT_PACK32; // see Rgb10A2 above case SpvImageFormatRg32ui: return VK_FORMAT_R32G32_UINT; case SpvImageFormatRg16ui: return VK_FORMAT_R16G16_UINT; case SpvImageFormatRg8ui: return VK_FORMAT_R8G8_UINT; diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index c0ea526e..3c6d100c 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -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}, diff --git a/MobileGL/MG_Util/Converters/MGToVk/TextureEnumConverter.cpp b/MobileGL/MG_Util/Converters/MGToVk/TextureEnumConverter.cpp index ab57de7f..c36069ec 100644 --- a/MobileGL/MG_Util/Converters/MGToVk/TextureEnumConverter.cpp +++ b/MobileGL/MG_Util/Converters/MGToVk/TextureEnumConverter.cpp @@ -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: From 3a12f6d4f3850e7c188714f5621b8438903f51a5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 03:21:09 -0400 Subject: [PATCH 02/23] [Fix, Test] (DirectGLES, ShaderTranspiler): emulate the 17 exactly-carriable non-core image formats by channel widening --- CMakeLists.txt | 1 + MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 29 +- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 259 ++++++++- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 38 ++ MobileGL/MG_Backend/DirectGLES/Utils.h | 57 ++ .../MG_Test/ShaderTranspiler/CMakeLists.txt | 1 + .../ShaderTranspiler/TranslationCacheTest.cpp | 7 + .../WidenImageFormatsTest.cpp | 397 +++++++++++++ MobileGL/MG_Test/Texture/TextureTest.cpp | 74 +++ .../ShaderTranspiler/ShaderCompiler.cpp | 30 + .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 25 + .../SpirvPasses/BakeImageFormatsPass.cpp | 15 +- .../SpirvPasses/WidenImageFormatsPass.cpp | 540 ++++++++++++++++++ .../SpirvPasses/WidenImageFormatsPass.h | 101 ++++ .../ShaderTranspiler/TranslationCache.cpp | 1 + .../ShaderTranspiler/TranslationCache.h | 5 + 16 files changed, 1551 insertions(+), 29 deletions(-) create mode 100644 MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d64f3f5e..5bb93da1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -294,6 +294,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.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 diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 2dbc8753..a09886f9 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 0da1f4f2..0d5b0c31 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -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& 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& 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 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 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 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,45 @@ 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). Armed only where there is no + // GL_NV_image_formats to spell the format natively - a driver that has the extension + // keeps the declaration and gets the `#extension` directive instead. + Bool ImageFormatWillBeWidened(Uint glInternalFormat) { + return !g_GLESCapabilities.SupportsExtendedImageFormats && glInternalFormat != 0 && + MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(glInternalFormat) != 0; + } } // namespace // What the format bake needs from the frontend, collected in one walk of the uniform @@ -5074,17 +5220,24 @@ 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(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(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(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.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 +5255,31 @@ 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.", - name.c_str(), unit, boundFormat); - recordUnspellableFormat( - name, MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(boundFormat)); - continue; + // Outside the GLSL ES core set and with no GL_NV_image_formats to spell + // it. If the format widens exactly it is still baked HERE, narrow, and + // WidenImageFormatsForEssl re-declares it in its core carrier immediately + // afterwards - both routes into that pass end in the same place. + // + // If it does not widen 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. + if (!ImageFormatWillBeWidened(boundFormat)) { + 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 +5311,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)); @@ -5197,8 +5368,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 @@ -5445,6 +5616,38 @@ 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. + // + // 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 ARMS this - + // see EsslTranslationKeyInputs::supportsExtendedImageFormats. The pass takes no other + // input: what it rewrites is a pure function of the module's own declared formats, + // and the module is already the largest thing in the L2 key. + // + // 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 widenedImageFormatSpirv; + if (!g_GLESCapabilities.SupportsExtendedImageFormats && + MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresWidenableImageFormat(*effectiveSpirv) && + MG_Util::ShaderTranspiler::ShaderCompiler::WidenImageFormatsForEssl( + *effectiveSpirv, widenedImageFormatSpirv, enableSpirvValidation) && + !widenedImageFormatSpirv.empty()) { + 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 @@ -5781,6 +5984,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; diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index a6a6bd8d..1f5a53ef 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -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 #include @@ -233,6 +234,43 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) { return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex()); } + + ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) { + // Same arming as WidenImageFormatsForEssl. A driver with GL_NV_image_formats spells + // the narrow format natively, and widening the storage behind a shader that still + // says `rg32f` would make the two disagree about the texel size. + if (g_GLESCapabilities.SupportsExtendedImageFormats) { + return {}; + } + const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); + const auto carrier = static_cast( + MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(requested)); + if (carrier == 0) { + 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{}, + nullptr, &widening.Format, &widening.Type); + return widening; + } } // namespace TextureImpl namespace PrgramImpl { String ProcessOutColorLocations(const String& glslCode) { diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index e4712ad1..0bae6e95 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -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 diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index 3235ce07..ba0df960 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable( ClampMultisampleFetchTest.cpp LegalizeStorageBlockArrayIndexTest.cpp FlattenAtomicCounterBlockTest.cpp + WidenImageFormatsTest.cpp ) target_include_directories(SpirvPassTest PRIVATE diff --git a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp index 2ad8d82c..e68f0af1 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp @@ -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; diff --git a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp new file mode 100644 index 00000000..294edd6a --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp @@ -0,0 +1,397 @@ +// 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 + +#define SPV_ENABLE_UTILITY_CODE +#include "glslang/SPIRV/spirv.hpp11" +#undef SPV_ENABLE_UTILITY_CODE + +#include "Includes.h" +#include "Init.h" +#include +#include + +#include + +#include +#include + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; + +namespace { + constexpr SizeT kSpirvHeaderWordCount = 5u; + + template + void ForEachInstruction(const Vector& 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(spirv[offset] & 0xffffu), &spirv[offset], wordCount); + offset += wordCount; + } + } + + Vector 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& 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 CollectStorageImageTypes(const Vector& spirv) { + Vector 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 components; + }; + + Vector CollectVectorShuffles(const Vector& spirv) { + Vector 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 CollectImageWriteTexelIds(const Vector& spirv) { + Vector 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 CollectImageReadResultIds(const Vector& spirv) { + Vector 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& expected) { + return shuffle.components == expected; + } + + const VectorShuffle* FindShuffleWithResult(const Vector& shuffles, Uint32 resultId) { + for (const VectorShuffle& shuffle : shuffles) { + if (shuffle.resultId == resultId) return &shuffle; + } + return nullptr; + } + + const VectorShuffle* FindShuffleOver(const Vector& shuffles, Uint32 firstVectorId) { + for (const VectorShuffle& shuffle : shuffles) { + if (shuffle.firstVectorId == firstVectorId) return &shuffle; + } + return nullptr; + } + + // 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 spirv = CompileFragment(kRg32fLoadStore); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); + + Vector widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*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(spv::ImageFormat::Rg32f)); + + const auto afterTypes = CollectStorageImageTypes(widened); + ASSERT_EQ(afterTypes.size(), 1u); + EXPECT_EQ(afterTypes.front().format, static_cast(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 spirv = CompileFragment(kR8uiLoadStore); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); + + Vector widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*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(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})); +} + +TEST(WidenImageFormats, CoreFormatModuleIsHandedBackUntouched) { + const Vector 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 widened; + ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*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(spv::ImageFormat::Rgba32f)); + } +} + +TEST(WidenImageFormats, FormatWithoutAnExactCarrierIsLeftAlone) { + const Vector spirv = CompileFragment(kRg16LoadStore); + ASSERT_FALSE(spirv.empty()); + + const auto beforeTypes = CollectStorageImageTypes(spirv); + ASSERT_EQ(beforeTypes.size(), 1u); + EXPECT_EQ(beforeTypes.front().format, static_cast(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 widened; + ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true); + if (!widened.empty()) { + const auto afterTypes = CollectStorageImageTypes(widened); + ASSERT_EQ(afterTypes.size(), 1u); + EXPECT_EQ(afterTypes.front().format, static_cast(spv::ImageFormat::Rg16)); + } +} diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index 8bdfc511..a638602f 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -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 widened; + const auto* result = static_cast( + PrepareChannelWidenedUpload(2, texelSize, source, sizeof(source), GL_FLOAT, widened, false)); + ASSERT_NE(result, static_cast(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 widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 1, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, /*integerData=*/true)); + ASSERT_NE(result, static_cast(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 widened; + const auto* result = static_cast(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(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 widened; + const auto* result = static_cast( + 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(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 widened; + EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, + false), + static_cast(source)); + EXPECT_TRUE(widened.empty()); + } +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 9c30ed6d..2343fcae 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -34,6 +34,7 @@ #include "SpirvPasses/NormalizeRectCoordinatesPass.h" #include "SpirvPasses/Lower1DArrayImagesPass.h" #include "SpirvPasses/BakeImageFormatsPass.h" +#include "SpirvPasses/WidenImageFormatsPass.h" #include "SpirvPasses/ClampMultisampleFetchPass.h" #include "SpirvPasses/PrivateToEntryLocalPass.h" #include "SpirvPasses/StripUniformLocationsPass.h" @@ -771,6 +772,35 @@ namespace MobileGL { BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(glInternalFormat)); } + bool ShaderCompiler::WidenImageFormatsForEssl(const Vector& inputBinary, + Vector& outputBinary, + const bool enableSpirvValidation) { + using namespace spvtools; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(WidenImageFormatsPass::CreateWidenImageFormatsPass()); + // 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& binary) { + return WidenImageFormatsPass::DeclaresWidenableImageFormat(binary); + } + + Uint ShaderCompiler::WidenedCoreEsslImageFormat(Uint glInternalFormat) { + return WidenImageFormatsPass::WidenedCoreEsslImageFormat(glInternalFormat); + } + + Uint ShaderCompiler::ImageFormatChannelCount(Uint glInternalFormat) { + return WidenImageFormatsPass::ImageFormatChannelCount(glInternalFormat); + } + bool ShaderCompiler::FlattenXfbInterfaceBlocksForEssl(const Vector& inputBinary, const std::set& blockNames, std::set& flattenedBlockNames, diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index f9d49220..3557aeff 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -231,6 +231,31 @@ 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. + static bool WidenImageFormatsForEssl(const Vector& inputBinary, + Vector& outputBinary, + bool enableSpirvValidation = false); + // Whether the module declares a storage image WidenImageFormatsForEssl would + // widen. One module parse, so the ~every shader that declares none pays no + // optimizer run. + static bool DeclaresWidenableImageFormat(const Vector& binary); + // 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& inputBinary, Vector& outputBinary, bool enableSpirvValidation = false); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp index aa5bfc51..cd04e94e 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp @@ -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(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(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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp new file mode 100644 index 00000000..07798e8d --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp @@ -0,0 +1,540 @@ +// 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" + +#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 +#include +#include + +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) { + if (type == nullptr || type->opcode() != spv::Op::OpTypeImage) return false; + if (type->GetSingleWordInOperand(kImageSampledOperand) != kSampledStorageImage) return false; + const auto format = + static_cast(type->GetSingleWordInOperand(kImageFormatOperand)); + return static_cast(WideningOfSpirvImageFormat(format)); + } + } // 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(const Vector& binary) { + std::unique_ptr 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; + } + for (const Instruction& type : context->module()->types_values()) { + if (IsWidenableStorageImageType(&type)) { + return true; + } + } + return false; + } + + 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 imageTypes; + for (Instruction& type : irContext->types_values()) { + if (IsWidenableStorageImageType(&type)) { + 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 widenedByTypeId; + for (Instruction* type : imageTypes) { + const auto format = + static_cast(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 reads; + std::vector 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 zeroOneConstantBySampledType; // sampled type id -> constant id + std::map 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 component; + if (sampledType->opcode() == spv::Op::OpTypeFloat && + sampledType->GetSingleWordInOperand(0) == 32) { + component = spvtools::MakeUnique(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( + 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 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( + 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( + 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; + type->SetInOperand(kImageFormatOperand, {static_cast(widenedIt->second.Carrier)}); + defUseMgr->AnalyzeInstUse(type); + } + + // 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() { + return spvtools::Optimizer::PassToken(spvtools::MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h new file mode 100644 index 00000000..aa280553 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h @@ -0,0 +1,101 @@ +// 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 + +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: + 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. + static bool DeclaresWidenableImageFormat(const Vector& binary); + + // 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(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 8a9f0bb6..00c58c50 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -162,6 +162,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { builder.Value(static_cast(inputs.shaderType)); builder.Value(static_cast(inputs.supportsViewportArray)); builder.Value(static_cast(inputs.supportsNoperspectiveInterpolation)); + builder.Value(static_cast(inputs.supportsExtendedImageFormats)); builder.Value(inputs.maxColorTextureSamples); builder.Value(inputs.maxIntegerSamples); builder.Value(inputs.maxDepthTextureSamples); diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index 980ddb6b..b371e8f0 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -602,6 +602,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // --- driver capability bits that arm or steer a pass --- Bool supportsViewportArray = false; Bool supportsNoperspectiveInterpolation = false; + // GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage + // image whose format GLSL ES core cannot spell in the core format that carries it and + // masks its accesses back - so a driver that HAS the extension and one that does not get + // materially different ESSL from the same module. + Bool supportsExtendedImageFormats = false; Int32 maxColorTextureSamples = 0; Int32 maxIntegerSamples = 0; Int32 maxDepthTextureSamples = 0; From 2a7d6f2e16194c0192f898d35496dd89b903d85f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 03:24:09 -0400 Subject: [PATCH 03/23] [Perf] (DirectGLES, ShaderTranspiler): fold the image-widening gate into the shared SPIR-V probe --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 32 ++++++++++++++++--- .../ShaderTranspiler/ShaderCompiler.cpp | 2 ++ .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 7 ++-- .../SpirvPasses/WidenImageFormatsPass.cpp | 19 +++++++---- .../SpirvPasses/WidenImageFormatsPass.h | 5 +++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 0d5b0c31..3dfc63aa 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5413,8 +5413,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // Declares* probes each cost a BuildModule per stage, and on a driver where both // gates are armed (Mali: no GL_OES_viewport_array AND integer multisample // squeezed to 1) the doubled parse made compile-heavy workloads ~10% slower. - // Probing the pre-lowering module is sound for both gates: demoting - // gl_ViewportIndex neither adds nor removes multisampled image types. + // Probing the pre-lowering module is sound for all three gates: demoting + // gl_ViewportIndex neither adds nor removes multisampled image types, and no pass + // between here and the widening changes which image FORMATS the module declares - + // Lower1DArrayImagesPass rebuilds an image type but copies its format operand across, + // and the one pass that can introduce a format (the bake) reports for itself below. // Recomputed here rather than calling GL_Getter's GetAdvertisedMaxSamples(): // this is backend code and must not reach into the GL frontend. 4 is that // translation unit's kFrontendMaxSamples, which is the source of truth - @@ -5426,8 +5429,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 real device (no driver tested advertises + // GL_NV_image_formats), 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. + const Bool imageFormatWideningArmed = !g_GLESCapabilities.SupportsExtendedImageFormats; MG_Util::ShaderTranspiler::ShaderCompiler::SpirvGateFeatures spirvGates; - if (viewportLoweringArmed || sampleClampArmed) { + if (viewportLoweringArmed || sampleClampArmed || imageFormatWideningArmed) { spirvGates = MG_Util::ShaderTranspiler::ShaderCompiler::ProbeSpirvGateFeatures( *effectiveSpirv); } @@ -5607,6 +5615,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // whose images all declare formats, and the cheap probe keeps a program that has // an unbound format-less image from paying an optimizer round trip per stage. Vector imageFormatSpirv; + Bool bakedAWidenableImageFormat = false; if (!imageFormatBake.glFormatByUniformName.empty() && MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresFormatlessStorageImage(*effectiveSpirv) && MG_Util::ShaderTranspiler::ShaderCompiler::BakeImageFormatsForEssl( @@ -5614,6 +5623,19 @@ namespace MobileGL::MG_Backend::DirectGLES { enableSpirvValidation) && !imageFormatSpirv.empty()) { effectiveSpirv = &imageFormatSpirv; + // The bake can put a format into the module that was not there when the gate + // probe above parsed it, so the probe's verdict is stale for exactly this case. + // Answered from the bake's own map rather than by re-parsing: an entry the map + // does not carry cannot have been baked. Conservative in the harmless direction - + // the pass declines individual uniforms the map names, and a widening run that + // finds nothing to widen only costs a re-serialisation of a module that already + // went through the optimizer one line above. + for (const auto& entry : imageFormatBake.glFormatByUniformName) { + if (MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(entry.second) != 0) { + bakedAWidenableImageFormat = true; + break; + } + } } // GL has forty image formats and GLSL ES core has thirteen; the other twenty-seven @@ -5640,8 +5662,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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 widenedImageFormatSpirv; - if (!g_GLESCapabilities.SupportsExtendedImageFormats && - MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresWidenableImageFormat(*effectiveSpirv) && + if (imageFormatWideningArmed && + (spirvGates.DeclaresWidenableImageFormat || bakedAWidenableImageFormat) && MG_Util::ShaderTranspiler::ShaderCompiler::WidenImageFormatsForEssl( *effectiveSpirv, widenedImageFormatSpirv, enableSpirvValidation) && !widenedImageFormatSpirv.empty()) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 2343fcae..550bbc62 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -728,6 +728,8 @@ namespace MobileGL { LowerViewportIndexPass::DeclaresViewportIndexBuiltin(context.get()); features.DeclaresMultisampledImage = ClampMultisampleFetchPass::DeclaresMultisampledImage(context.get()); + features.DeclaresWidenableImageFormat = + WidenImageFormatsPass::DeclaresWidenableImageFormat(context.get()); return features; } diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 3557aeff..99497620 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -74,6 +74,7 @@ namespace MobileGL { struct SpirvGateFeatures { Bool WritesViewportIndexOutput = false; Bool DeclaresMultisampledImage = false; + Bool DeclaresWidenableImageFormat = false; }; static SpirvGateFeatures ProbeSpirvGateFeatures(const Vector& binary); // Replaces an ARRAY vertex input with one input per element at consecutive @@ -244,8 +245,10 @@ namespace MobileGL { Vector& outputBinary, bool enableSpirvValidation = false); // Whether the module declares a storage image WidenImageFormatsForEssl would - // widen. One module parse, so the ~every shader that declares none pays no - // optimizer run. + // widen, so the ~every shader that declares none pays no optimizer run. Costs its + // own module parse: the transpile path asks the same question through + // ProbeSpirvGateFeatures instead, because this gate is armed on every real driver + // and would otherwise put a BuildModule on every stage of every program. static bool DeclaresWidenableImageFormat(const Vector& binary); // 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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp index 07798e8d..9a47b32a 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp @@ -231,11 +231,8 @@ namespace MobileGL { return ChannelsOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat)); } - bool WidenImageFormatsPass::DeclaresWidenableImageFormat(const Vector& binary) { - std::unique_ptr 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) { + bool WidenImageFormatsPass::DeclaresWidenableImageFormat(IRContext* context) { + if (context == nullptr) { return false; } for (const Instruction& type : context->module()->types_values()) { @@ -246,6 +243,13 @@ namespace MobileGL { return false; } + bool WidenImageFormatsPass::DeclaresWidenableImageFormat(const Vector& binary) { + std::unique_ptr 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()); + } + spvtools::opt::Pass::Status WidenImageFormatsPass::Process() { auto* irContext = context(); auto* defUseMgr = irContext->get_def_use_mgr(); @@ -519,8 +523,11 @@ namespace MobileGL { 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(widenedIt->second.Carrier)}); - defUseMgr->AnalyzeInstUse(type); } // StorageImageExtendedFormats is deliberately left declared even though every diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h index aa280553..b9d13013 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h @@ -82,6 +82,11 @@ namespace MobileGL { // the caller can skip the optimizer run entirely - which is every shader but a // handful. static bool DeclaresWidenableImageFormat(const Vector& binary); + // 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); // 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 From 80ea44573eec0b307f114737fb2e547f392851c7 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 03:34:27 -0400 Subject: [PATCH 04/23] [Test] (ShaderTranspiler): pin the ESSL a widened image module emits, on both SPIRV-Cross failure modes --- .../WidenImageFormatsTest.cpp | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp index 294edd6a..29e143cc 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp @@ -31,6 +31,7 @@ #include "Includes.h" #include "Init.h" #include +#include #include #include @@ -163,6 +164,35 @@ namespace { 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& 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 @@ -357,6 +387,54 @@ TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccesses 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 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 widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, 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 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 widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, 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; + } +} + TEST(WidenImageFormats, CoreFormatModuleIsHandedBackUntouched) { const Vector spirv = CompileFragment(kCoreFormatLoadStore); ASSERT_FALSE(spirv.empty()); From b164692387b532184cd83f8eeff9b97d86c782bb Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 03:48:59 -0400 Subject: [PATCH 05/23] [Test] (ShaderTranspiler): retarget the bake-decline pin at the formats no core carrier rescues --- MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 48 ++++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 7478bce6..eb91e470 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -3674,6 +3674,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 +3717,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 +3746,29 @@ void main() { imageStore(uni_image, ivec2(0), uvec4(15u)); } GL_COMPUTE_SHADER); ASSERT_FALSE(spirv.empty()); - Vector 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 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 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 widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(baked, widened, 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 From 07d6277f8765b252c5f52dc01b51f151f8dc2c8c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 03:54:16 -0400 Subject: [PATCH 06/23] [Test] (MG_IntegrationTest): cover GL's missing-channel semantics for a non-core image format --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Scenarios/NonCoreImageFormatScenario.cpp | 402 ++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index e14dc712..04a76493 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -81,6 +81,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ImageLoadStoreSsoScenario.cpp Scenarios/ImageTargetKindScenario.cpp Scenarios/ImageFormatQualifierScenario.cpp + Scenarios/NonCoreImageFormatScenario.cpp Scenarios/ImageSizeAfterRespecScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp new file mode 100644 index 00000000..60f45bb6 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp @@ -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 +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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(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(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 m_programs; + std::vector m_textures; + + std::vector ReadFloats(GLuint texture, GLenum format, int componentsPerTexel) { + std::vector texels(static_cast(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 ReadUints(GLuint texture, GLenum format, int componentsPerTexel) { + std::vector texels(static_cast(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 seed(static_cast(kExtent) * kExtent * 2u, -1.0f); + const std::vector wideSeed(static_cast(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 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 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 seed(static_cast(kExtent) * kExtent, 200u); + const std::vector wideSeed(static_cast(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 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 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 seed(static_cast(kExtent) * kExtent * 2u, -1.0f); + const std::vector wideSeed(static_cast(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 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 From 668f3e90c97164a6785bfe11fb6afdf3c4de1e5c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 04:13:01 -0400 Subject: [PATCH 07/23] [Fix] (DirectGLES, ShaderTranspiler): widen the formats SPIRV-Cross refuses to print even where GL_NV_image_formats exists --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 90 +++++++++++-------- MobileGL/MG_Backend/DirectGLES/Managers.h | 8 ++ MobileGL/MG_Backend/DirectGLES/Utils.cpp | 20 +++-- MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 2 +- .../WidenImageFormatsTest.cpp | 65 ++++++++++++-- .../ShaderTranspiler/ShaderCompiler.cpp | 12 +-- .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 22 +++-- .../SpirvPasses/WidenImageFormatsPass.cpp | 34 +++++-- .../SpirvPasses/WidenImageFormatsPass.h | 32 ++++++- 9 files changed, 209 insertions(+), 76 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 3dfc63aa..cea0df7d 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5181,12 +5181,23 @@ namespace MobileGL::MG_Backend::DirectGLES { } // Whether the ESSL chain will re-declare an image of this format in a core carrier - // and mask its accesses (WidenImageFormatsForEssl). Armed only where there is no - // GL_NV_image_formats to spell the format natively - a driver that has the extension - // keeps the declaration and gets the `#extension` directive instead. + // 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) { - return !g_GLESCapabilities.SupportsExtendedImageFormats && glInternalFormat != 0 && - MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(glInternalFormat) != 0; + 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 @@ -5229,7 +5240,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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))) { + 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 @@ -5277,6 +5290,9 @@ namespace MobileGL::MG_Backend::DirectGLES { name, MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(boundFormat)); continue; } + // 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. + inputs.declaresWidenableImageFormat = true; } else { inputs.needsExtendedImageFormats = true; } @@ -5413,11 +5429,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Declares* probes each cost a BuildModule per stage, and on a driver where both // gates are armed (Mali: no GL_OES_viewport_array AND integer multisample // squeezed to 1) the doubled parse made compile-heavy workloads ~10% slower. - // Probing the pre-lowering module is sound for all three gates: demoting - // gl_ViewportIndex neither adds nor removes multisampled image types, and no pass - // between here and the widening changes which image FORMATS the module declares - - // Lower1DArrayImagesPass rebuilds an image type but copies its format operand across, - // and the one pass that can introduce a format (the bake) reports for itself below. + // Probing the pre-lowering module is sound for both gates: demoting + // gl_ViewportIndex neither adds nor removes multisampled image types. // Recomputed here rather than calling GL_Getter's GetAdvertisedMaxSamples(): // this is backend code and must not reach into the GL frontend. 4 is that // translation unit's kFrontendMaxSamples, which is the source of truth - @@ -5429,13 +5442,15 @@ 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 real device (no driver tested advertises - // GL_NV_image_formats), 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. - const Bool imageFormatWideningArmed = !g_GLESCapabilities.SupportsExtendedImageFormats; + // 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 || imageFormatWideningArmed) { + if (viewportLoweringArmed || sampleClampArmed) { spirvGates = MG_Util::ShaderTranspiler::ShaderCompiler::ProbeSpirvGateFeatures( *effectiveSpirv); } @@ -5615,7 +5630,6 @@ namespace MobileGL::MG_Backend::DirectGLES { // whose images all declare formats, and the cheap probe keeps a program that has // an unbound format-less image from paying an optimizer round trip per stage. Vector imageFormatSpirv; - Bool bakedAWidenableImageFormat = false; if (!imageFormatBake.glFormatByUniformName.empty() && MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresFormatlessStorageImage(*effectiveSpirv) && MG_Util::ShaderTranspiler::ShaderCompiler::BakeImageFormatsForEssl( @@ -5623,19 +5637,6 @@ namespace MobileGL::MG_Backend::DirectGLES { enableSpirvValidation) && !imageFormatSpirv.empty()) { effectiveSpirv = &imageFormatSpirv; - // The bake can put a format into the module that was not there when the gate - // probe above parsed it, so the probe's verdict is stale for exactly this case. - // Answered from the bake's own map rather than by re-parsing: an entry the map - // does not carry cannot have been baked. Conservative in the harmless direction - - // the pass declines individual uniforms the map names, and a widening run that - // finds nothing to widen only costs a re-serialisation of a module that already - // went through the optimizer one line above. - for (const auto& entry : imageFormatBake.glFormatByUniformName) { - if (MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(entry.second) != 0) { - bakedAWidenableImageFormat = true; - break; - } - } } // GL has forty image formats and GLSL ES core has thirteen; the other twenty-seven @@ -5650,23 +5651,34 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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 ARMS this - - // see EsslTranslationKeyInputs::supportsExtendedImageFormats. The pass takes no other - // input: what it rewrites is a pure function of the module's own declared formats, - // and the module is already the largest thing in the L2 key. + // 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 widenedImageFormatSpirv; - if (imageFormatWideningArmed && - (spirvGates.DeclaresWidenableImageFormat || bakedAWidenableImageFormat) && + if (imageFormatBake.declaresWidenableImageFormat && MG_Util::ShaderTranspiler::ShaderCompiler::WidenImageFormatsForEssl( - *effectiveSpirv, widenedImageFormatSpirv, enableSpirvValidation) && - !widenedImageFormatSpirv.empty()) { + *effectiveSpirv, widenedImageFormatSpirv, widenOnlyUnprintableImageFormats, + enableSpirvValidation) && + !widenedImageFormatSpirv.empty() && widenedImageFormatSpirv != *effectiveSpirv) { effectiveSpirv = &widenedImageFormatSpirv; } diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index a289a242..a0b54dd2 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -1398,6 +1398,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); diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 1f5a53ef..be295362 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -236,18 +236,26 @@ namespace MobileGL::MG_Backend::DirectGLES { } ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) { - // Same arming as WidenImageFormatsForEssl. A driver with GL_NV_image_formats spells - // the narrow format natively, and widening the storage behind a shader that still - // says `rg32f` would make the two disagree about the texel size. - if (g_GLESCapabilities.SupportsExtendedImageFormats) { - return {}; - } const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); const auto carrier = static_cast( 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 = diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index eb91e470..4a680bd0 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -3762,7 +3762,7 @@ void main() { imageStore(uni_image, ivec2(0), uvec4(15u)); } ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(baked)); Vector widened; - ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(baked, widened, true)); + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(baked, widened, false, true)); ASSERT_FALSE(widened.empty()); const String essl = DecompileToEssl(widened); ASSERT_FALSE(essl.empty()); diff --git a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp index 29e143cc..8f94f164 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp @@ -319,7 +319,8 @@ TEST(WidenImageFormats, TwoChannelFloatImageBecomesRgba32fWithBothAccessesMasked ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); Vector widened; - ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true)); + 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. @@ -362,7 +363,8 @@ TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccesses ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); Vector widened; - ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true)); + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false, + /*enableSpirvValidation=*/true)); ASSERT_FALSE(widened.empty()); EXPECT_TRUE(Validates(widened)); @@ -406,7 +408,7 @@ TEST(WidenImageFormats, WidenedModulesEmitEsslNamingTheCoreCarrier) { << before.text; Vector widened; - ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, true)); + 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; @@ -425,7 +427,7 @@ TEST(WidenImageFormats, WidenedModulesEmitEsslNamingTheCoreCarrier) { EXPECT_NE(before.text.find("rg32f"), String::npos) << before.text; Vector widened; - ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, true)); + 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; @@ -435,6 +437,55 @@ TEST(WidenImageFormats, WidenedModulesEmitEsslNamingTheCoreCarrier) { } } +// 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 spirv = CompileFragment(kRg32fLoadStore); + ASSERT_FALSE(spirv.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, false)); + EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, true)); + + Vector 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(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 spirv = CompileFragment(kR8uiLoadStore); + ASSERT_FALSE(spirv.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, true)); + + Vector 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(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 spirv = CompileFragment(kCoreFormatLoadStore); ASSERT_FALSE(spirv.empty()); @@ -442,7 +493,8 @@ TEST(WidenImageFormats, CoreFormatModuleIsHandedBackUntouched) { EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); Vector widened; - ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true); + 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"; @@ -466,7 +518,8 @@ TEST(WidenImageFormats, FormatWithoutAnExactCarrierIsLeftAlone) { EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); Vector widened; - ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true); + ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false, + /*enableSpirvValidation=*/true); if (!widened.empty()) { const auto afterTypes = CollectStorageImageTypes(widened); ASSERT_EQ(afterTypes.size(), 1u); diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 550bbc62..bd15b5aa 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -728,8 +728,6 @@ namespace MobileGL { LowerViewportIndexPass::DeclaresViewportIndexBuiltin(context.get()); features.DeclaresMultisampledImage = ClampMultisampleFetchPass::DeclaresMultisampledImage(context.get()); - features.DeclaresWidenableImageFormat = - WidenImageFormatsPass::DeclaresWidenableImageFormat(context.get()); return features; } @@ -776,10 +774,12 @@ namespace MobileGL { bool ShaderCompiler::WidenImageFormatsForEssl(const Vector& inputBinary, Vector& outputBinary, + const bool onlyFormatsSpirvCrossRefusesToPrint, const bool enableSpirvValidation) { using namespace spvtools; Optimizer optimizer(SPV_ENV_VULKAN_1_1); - optimizer.RegisterPass(WidenImageFormatsPass::CreateWidenImageFormatsPass()); + 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 @@ -791,8 +791,10 @@ namespace MobileGL { true, enableSpirvValidation); } - bool ShaderCompiler::DeclaresWidenableImageFormat(const Vector& binary) { - return WidenImageFormatsPass::DeclaresWidenableImageFormat(binary); + bool ShaderCompiler::DeclaresWidenableImageFormat(const Vector& binary, + const bool onlyFormatsSpirvCrossRefusesToPrint) { + return WidenImageFormatsPass::DeclaresWidenableImageFormat(binary, + onlyFormatsSpirvCrossRefusesToPrint); } Uint ShaderCompiler::WidenedCoreEsslImageFormat(Uint glInternalFormat) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 99497620..5f844d6f 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -71,10 +71,14 @@ 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; - Bool DeclaresWidenableImageFormat = false; }; static SpirvGateFeatures ProbeSpirvGateFeatures(const Vector& binary); // Replaces an ARRAY vertex input with one input per element at consecutive @@ -241,15 +245,21 @@ namespace MobileGL { // 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& inputBinary, Vector& outputBinary, + bool onlyFormatsSpirvCrossRefusesToPrint = false, bool enableSpirvValidation = false); // Whether the module declares a storage image WidenImageFormatsForEssl would - // widen, so the ~every shader that declares none pays no optimizer run. Costs its - // own module parse: the transpile path asks the same question through - // ProbeSpirvGateFeatures instead, because this gate is armed on every real driver - // and would otherwise put a BuildModule on every stage of every program. - static bool DeclaresWidenableImageFormat(const Vector& binary); + // 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& 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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp index 9a47b32a..2b074fd5 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp @@ -8,6 +8,10 @@ #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" @@ -211,12 +215,20 @@ namespace MobileGL { } } - Bool IsWidenableStorageImageType(const Instruction* type) { + 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(type->GetSingleWordInOperand(kImageFormatOperand)); - return static_cast(WideningOfSpirvImageFormat(format)); + if (!WideningOfSpirvImageFormat(format)) return false; + if (onlyFormatsSpirvCrossRefusesToPrint && + BakeImageFormatsPass::IsSpirvCrossEsslPrintableFormat(static_cast(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 @@ -231,23 +243,25 @@ namespace MobileGL { return ChannelsOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat)); } - bool WidenImageFormatsPass::DeclaresWidenableImageFormat(IRContext* context) { + bool WidenImageFormatsPass::DeclaresWidenableImageFormat( + IRContext* context, const bool onlyFormatsSpirvCrossRefusesToPrint) { if (context == nullptr) { return false; } for (const Instruction& type : context->module()->types_values()) { - if (IsWidenableStorageImageType(&type)) { + if (IsWidenableStorageImageType(&type, onlyFormatsSpirvCrossRefusesToPrint)) { return true; } } return false; } - bool WidenImageFormatsPass::DeclaresWidenableImageFormat(const Vector& binary) { + bool WidenImageFormatsPass::DeclaresWidenableImageFormat( + const Vector& binary, const bool onlyFormatsSpirvCrossRefusesToPrint) { std::unique_ptr 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()); + return DeclaresWidenableImageFormat(context.get(), onlyFormatsSpirvCrossRefusesToPrint); } spvtools::opt::Pass::Status WidenImageFormatsPass::Process() { @@ -258,7 +272,7 @@ namespace MobileGL { // byte-identical - which is every shader but a handful. std::vector imageTypes; for (Instruction& type : irContext->types_values()) { - if (IsWidenableStorageImageType(&type)) { + if (IsWidenableStorageImageType(&type, m_onlyFormatsSpirvCrossRefusesToPrint)) { imageTypes.push_back(&type); } } @@ -539,8 +553,10 @@ namespace MobileGL { return Status::SuccessWithChange; } - spvtools::Optimizer::PassToken WidenImageFormatsPass::CreateWidenImageFormatsPass() { - return spvtools::Optimizer::PassToken(spvtools::MakeUnique()); + spvtools::Optimizer::PassToken WidenImageFormatsPass::CreateWidenImageFormatsPass( + const bool onlyFormatsSpirvCrossRefusesToPrint) { + return spvtools::Optimizer::PassToken( + spvtools::MakeUnique(onlyFormatsSpirvCrossRefusesToPrint)); } } // namespace ShaderTranspiler } // namespace MG_Util diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h index b9d13013..f5d7be0a 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h @@ -74,19 +74,39 @@ namespace MobileGL { // 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. - static bool DeclaresWidenableImageFormat(const Vector& binary); + // 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& 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); + 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 @@ -99,7 +119,11 @@ namespace MobileGL { // forty image formats. The count the widened accesses are masked back to. static Uint ImageFormatChannelCount(Uint glInternalFormat); - static spvtools::Optimizer::PassToken CreateWidenImageFormatsPass(); + static spvtools::Optimizer::PassToken CreateWidenImageFormatsPass( + bool onlyFormatsSpirvCrossRefusesToPrint = false); + + private: + bool m_onlyFormatsSpirvCrossRefusesToPrint = false; }; } // namespace ShaderTranspiler } // namespace MG_Util From 02b59bef807c45e3bc273b293a2dd9394d7f0f6c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 04:28:44 -0400 Subject: [PATCH 08/23] [Fix, Test] (DirectGLES): give every repaired image uniform a per-stage name so no linker can merge two stages' qualifiers --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 5 +- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 94 +++++-- MobileGL/MG_Backend/DirectGLES/Utils.h | 32 ++- .../Backend/DirectGLES/EsslShaderPassTest.cpp | 245 +++++++++++++----- 4 files changed, 288 insertions(+), 88 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index cea0df7d..32d17b19 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -6192,8 +6192,11 @@ 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. + // * and it needs the STAGE, because the qualifier it adds is a per-stage decision + // and the rename that keeps two stages from declaring one image uniform + // differently is keyed on it. Uint splitImageUniformCount = 0; - source = SplitReadWriteImageUniforms(source, &splitImageUniformCount); + source = SplitReadWriteImageUniforms(source, glShaderType, &splitImageUniformCount); if (splitImageUniformCount != 0) { splitImageUniformStages.push_back({shader->GetShaderStage(), splitImageUniformCount}); } diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index be295362..8b18c29e 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -857,6 +857,8 @@ namespace MobileGL::MG_Backend::DirectGLES { struct ImageUniformDecl { String name; + String stageName; // the stage-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 @@ -901,11 +903,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& 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& 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; @@ -951,7 +953,22 @@ namespace MobileGL::MG_Backend::DirectGLES { } } // namespace - String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount) { + String ImageStageAliasPrefix(GLenum shaderType) { + switch (shaderType) { + case GL_VERTEX_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Vs_"; + case GL_FRAGMENT_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Fs_"; + case GL_COMPUTE_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Cs_"; + case GL_GEOMETRY_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Gs_"; + case GL_TESS_CONTROL_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Tcs_"; + case GL_TESS_EVALUATION_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Tes_"; + // A stage this build does not know. Still a tag of its own rather than the bare + // name, so the anti-collision property below never depends on the switch being + // exhaustive - it only stops being able to say WHICH stage a name came from. + default: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Xs_"; + } + } + + String SplitReadWriteImageUniforms(const String& glslCode, GLenum shaderType, Uint* outSplitCount) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -1014,13 +1031,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 storeSites; + Vector 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; @@ -1065,12 +1086,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; @@ -1087,12 +1112,40 @@ namespace MobileGL::MG_Backend::DirectGLES { } Vector edits; - Vector takenAliases; + Vector takenNames; + const String stagePrefix = ImageStageAliasPrefix(shaderType); for (auto& decl : decls) { if (decl.unknownUse) continue; // leave it exactly as it was; no guessing + // EVERY declaration this pass rewrites is also RENAMED, per stage - the + // qualifier it is about to add is a per-stage decision, 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). A + // per-stage name means there is no cross-stage variable left to merge. + // + // Unconditional, rather than only when another stage is known to declare the same + // name, because this pass sees ONE stage at a time and a conditional rename would + // have to guess. 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. + decl.stageName = MakeImageAliasName(stagePrefix, decl.name, glslCode, takenNames); + takenNames.push_back(decl.stageName); if (decl.loaded && decl.stored) { - decl.writeName = MakeImageWriteAliasName(decl.name, glslCode, takenAliases); - takenAliases.push_back(decl.writeName); + // Minted from the ALREADY stage-tagged name, so two stages that both split + // the same image do not collide on the write half either. + decl.writeName = + MakeImageAliasName(IMAGE_WRITE_ALIAS_PREFIX, decl.stageName, glslCode, takenNames); + takenNames.push_back(decl.writeName); decl.split = true; if (outSplitCount != nullptr) ++*outSplitCount; // Both halves carry `coherent`; see BuildImageDeclaration. The @@ -1100,24 +1153,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.stageName, + /*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.stageName)}); } 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.stageName)}); } } - 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.stageName.empty()) continue; + edits.push_back( + {site.start, site.length, decl.split && site.stores ? decl.writeName : decl.stageName}); + 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 diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 0bae6e95..067827b7 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -236,8 +236,15 @@ namespace MobileGL::MG_Backend::DirectGLES { String BakeImageFormatQualifiers(String glslCode, const UnorderedMap& esslFormatByUniformName); String RemoveLayoutBinding(const String& glslCode); // 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 stage-tagged) name. constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_"; + // Stem of the per-stage name every image declaration SplitReadWriteImageUniforms rewrites + // is renamed under; ImageStageAliasPrefix appends the stage tag and the separator. + constexpr const char* IMAGE_STAGE_ALIAS_PREFIX = "mg_image"; + // "mg_imageVs_", "mg_imageFs_", "mg_imageCs_", ... - the prefix SplitReadWriteImageUniforms + // renames a rewritten image declaration under, so that no two stages can end up declaring + // the same image uniform name with different memory qualifiers. Exposed for the tests. + String ImageStageAliasPrefix(GLenum shaderType); // 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). @@ -249,7 +256,7 @@ 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: + // Restores a legal declaration, and RENAMES it per stage while doing so: // * loaded only -> add `readonly` // * stored only -> add `writeonly` // * both -> emit TWO declarations on the same binding and of the @@ -261,6 +268,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // the same type and format, which is exactly what the pair // is. // + // The rename (ImageStageAliasPrefix) 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`). A per-stage name leaves no cross-stage variable to merge. Every + // rewritten declaration is renamed, including the readonly half of a split pair; 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 @@ -284,13 +305,16 @@ 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. It is downstream of the L2 shader-translation memo (which stores + // what SPIRV-Cross emitted, before any of these text passes), so `shaderType` steering the + // names it mints needs no entry in BuildEsslTranslationKey. // // `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 // application declared. Zero for every shader but a handful, and the only number the // budget note above can be reported with. - String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount = nullptr); + String SplitReadWriteImageUniforms(const String& glslCode, GLenum shaderType, + Uint* outSplitCount = nullptr); // Prefix of the per-sampler float uniform that carries GL_TEXTURE_LOD_BIAS into // the shader (see EmulateTextureLodBias); the suffix is the sampler's own name. constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_"; diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index 91ead160..5d707419 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -18,7 +18,9 @@ using namespace MobileGL; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BakeImageFormatQualifiers; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_STAGE_ALIAS_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageStageAliasPrefix; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension; @@ -37,7 +39,14 @@ namespace { return count; } + // Every fixture below is a fragment shader unless it says otherwise. The pass tags the name + // of every declaration it rewrites with the stage it ran on, so the expectations have to + // spell the same tag. + constexpr GLenum kStage = GL_FRAGMENT_SHADER; + String StageAlias(const String& name) { return ImageStageAliasPrefix(kStage) + name; } + // The writeonly half is minted from the ALREADY stage-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(StageAlias(name)); } } // namespace // The bug the pass exists for. SPIRV-Cross speculatively marks every storage image @@ -56,19 +65,24 @@ void main() mg_FragColor = loaded; } )"; - const String out = SplitReadWriteImageUniforms(source); + const String out = SplitReadWriteImageUniforms(source, kStage); // 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 " + + StageAlias("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(" + StageAlias("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 @@ -82,7 +96,7 @@ void main() imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); } )"; - const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source)); + const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source, kStage)); EXPECT_EQ(CountOf(out, "binding = 5"), 2u); } @@ -97,8 +111,11 @@ void main() mg_FragColor = imageLoad(trunks, ivec3(0)); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray trunks;")); + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray " + + StageAlias("trunks") + ";")) + << out; + EXPECT_TRUE(Contains(out, "imageLoad(" + StageAlias("trunks") + ",")); EXPECT_FALSE(Contains(out, "writeonly")); EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); EXPECT_EQ(CountOf(out, "image2DArray"), 1u); @@ -112,8 +129,11 @@ void main() imageStore(gohan, ivec2(0), vec4(1.0)); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D gohan;")); + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE( + Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D " + StageAlias("gohan") + ";")) + << out; + EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias("gohan") + ",")); EXPECT_FALSE(Contains(out, "readonly")); EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); } @@ -126,7 +146,7 @@ TEST(SplitReadWriteImageUniformsTest, ExemptFormatsAreLeftCompletelyAlone) { const String source = "#version 320 es\nlayout(binding = 4, " + String(format) + ") uniform highp " + type + " vegeta;\nvoid main()\n{\n imageStore(vegeta, ivec2(0), imageLoad(vegeta, " "ivec2(0)));\n}\n"; - EXPECT_EQ(SplitReadWriteImageUniforms(source), source) << "format " << format; + EXPECT_EQ(SplitReadWriteImageUniforms(source, kStage), source) << "format " << format; } } @@ -140,7 +160,10 @@ void main() imageStore(writer, ivec2(0), imageLoad(reader, ivec2(0))); } )"; - EXPECT_EQ(SplitReadWriteImageUniforms(source), source); + // 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, kStage), source); } // The binding of an image array is the array's base; splitting must keep the array on both @@ -153,12 +176,15 @@ void main() imageStore(gohan[1], ivec2(0), imageLoad(gohan[2], ivec2(0))); } )"; - 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],")); + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent readonly highp image2D " + + StageAlias("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(" + StageAlias("gohan") + "[2],")); } // The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and @@ -174,17 +200,22 @@ void main() imageStore(goku_hd, ivec2(0), loaded); } )"; - const String out = SplitReadWriteImageUniforms(source); + const String out = SplitReadWriteImageUniforms(source, kStage); // 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 " + + StageAlias("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 " + + StageAlias("goku_hd") + ";")) + << out; + EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias("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 @@ -197,10 +228,12 @@ void main() imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D goku;")); + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D " + StageAlias("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); @@ -224,13 +257,14 @@ void main() imageStore(storeOnly, ivec2(0), vec4(2.0)); } )"; - 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; + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + StageAlias("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 " + StageAlias("storeOnly") + ";")) << out; } // The ORDERING half of the split, which `coherent` alone does not buy. Coherent makes the store @@ -251,11 +285,13 @@ void main() mg_FragColor = first + imageLoad(goku, ivec2(0)); } )"; - const String out = SplitReadWriteImageUniforms(source); + const String out = SplitReadWriteImageUniforms(source, kStage); - 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; @@ -272,8 +308,8 @@ void main() imageStore(storeOnly, ivec2(0), vec4(1.0)); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D storeOnly;")) << out; + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + StageAlias("storeOnly") + ";")) << out; EXPECT_FALSE(Contains(out, "memoryBarrierImage")) << out; } @@ -288,8 +324,10 @@ void main() imageStore(gohan[1], ivec2(0), max(imageLoad(gohan[2], ivec2(0)), vec4(0.5))); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "max(imageLoad(gohan[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();")) << out; + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "max(imageLoad(" + StageAlias("gohan") + + "[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();")) + << out; EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 1u) << out; } @@ -312,7 +350,7 @@ void main() } )"; Uint splitCount = 99u; - SplitReadWriteImageUniforms(twoSplits, &splitCount); + SplitReadWriteImageUniforms(twoSplits, kStage, &splitCount); EXPECT_EQ(splitCount, 2u) << "only the read+write pair counts; the store-only repair adds no uniform"; // Every early return has to write the count too, or a caller reads whatever was there before. @@ -324,7 +362,7 @@ void main() } )"; splitCount = 99u; - SplitReadWriteImageUniforms(noImages, &splitCount); + SplitReadWriteImageUniforms(noImages, kStage, &splitCount); EXPECT_EQ(splitCount, 0u); } @@ -339,26 +377,39 @@ void main() mg_FragColor = vec4(float(imageSize(sizeOnly).x)); } )"; - const String out = SplitReadWriteImageUniforms(source); - EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D sizeOnly;")); + const String out = SplitReadWriteImageUniforms(source, kStage); + EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D " + + StageAlias("sizeOnly") + ";")) + << out; + // The rename has to reach imageSize too, or the declaration and its only use stop agreeing. + EXPECT_TRUE(Contains(out, "imageSize(" + StageAlias("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); -} -)"; - 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)")); +// Neither minted name may land on an identifier the shader already uses - and there are two of +// them now, the stage-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 = StageAlias("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, kStage); + + 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,7 +423,10 @@ void main() imageStore(passed, ivec2(0), helper(passed)); } )"; - EXPECT_EQ(SplitReadWriteImageUniforms(source), source); + // 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, kStage), source); } TEST(SplitReadWriteImageUniformsTest, ShaderWithoutImagesIsReturnedUnchanged) { @@ -384,7 +438,68 @@ void main() mg_FragColor = texture(goku, vec2(0.5)); } )"; - EXPECT_EQ(SplitReadWriteImageUniforms(source), source); + EXPECT_EQ(SplitReadWriteImageUniforms(source, kStage), 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). Stage-tagged names leave +// nothing to merge. +TEST(SplitReadWriteImageUniformsTest, TheSameImageGetsADifferentNameInEachStage) { + 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, GL_VERTEX_SHADER); + const String fsOut = SplitReadWriteImageUniforms(fragmentSource, GL_FRAGMENT_SHADER); + + const String vsName = ImageStageAliasPrefix(GL_VERTEX_SHADER) + "g_image"; + const String fsName = ImageStageAliasPrefix(GL_FRAGMENT_SHADER) + "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")); +} + +// Every stage gets a tag of its own, including the ones a fragment/vertex pair never exercises. +TEST(SplitReadWriteImageUniformsTest, EveryStageTagIsDistinct) { + const GLenum stages[] = {GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, GL_COMPUTE_SHADER, + GL_GEOMETRY_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER}; + Vector prefixes; + for (const GLenum stage : stages) { + const String prefix = ImageStageAliasPrefix(stage); + EXPECT_EQ(prefix.rfind(IMAGE_STAGE_ALIAS_PREFIX, 0), 0u) << prefix; + // 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 : prefixes) { + EXPECT_NE(seen, prefix) << prefix; + } + prefixes.push_back(prefix); + } } // --------------------------------------------------------------------------------------- @@ -629,7 +744,7 @@ layout(binding = 3) uniform writeonly highp uimage2D uni_image; void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } )"; String out = BakeImageFormatQualifiers(source, {{"uni_image", "r8ui"}}); - out = SplitReadWriteImageUniforms(out); + out = SplitReadWriteImageUniforms(out, kStage); out = RemoveLayoutBinding(out); EXPECT_TRUE(Contains(out, "r8ui")) << out; EXPECT_TRUE(Contains(out, "binding = 3")) << out; From 6317066add25e9e89fa52b274a951e34cb25b788 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 04:38:59 -0400 Subject: [PATCH 09/23] [Fix, Test] (DirectGLES): reach an image array's non-consecutive units by widening the array over their span --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 76 ++++++ MobileGL/MG_Backend/DirectGLES/Utils.cpp | 216 ++++++++++++++++++ MobileGL/MG_Backend/DirectGLES/Utils.h | 45 ++++ .../Scenarios/ImageLoadStoreSsoScenario.cpp | 35 ++- .../Backend/DirectGLES/EsslShaderPassTest.cpp | 168 ++++++++++++++ 5 files changed, 530 insertions(+), 10 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 32d17b19..c5abeea7 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5357,6 +5357,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 CollectNonConsecutiveImageArrayPlans( + const MG_State::GLState::ProgramObject& stateProgramObject) { + Vector 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(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(loc), static_cast(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(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; @@ -5854,6 +5900,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 nonConsecutiveImageArrays = + CollectNonConsecutiveImageArrayPlans(*stateProgramObject); // Detach all existing shaders GLint attachedCount = 0; @@ -6184,6 +6235,31 @@ 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. Widen the array to + // cover the span and route its subscripts through a constant offset table. 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 both halves inherit the widened extent and the rebased binding. + if (!nonConsecutiveImageArrays.empty()) { + Vector declinedImageArrays; + source = RemapImageArrayElementUnits( + source, nonConsecutiveImageArrays, + AdvertisedStageImageUniformLimit(shader->GetShaderStage()), &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 diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 8b18c29e..d805ef9e 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -968,6 +968,222 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + namespace { + // The digits of an array extent, or -1 for "not a plain literal size". + Int ParseArrayExtent(const String& text) { + if (text.empty()) return -1; + Int value = 0; + for (const char c : text) { + 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& plans, + const Int stageImageUniformBudget, Vector* 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, because the budget test below is about the + // stage's total and not about this one array. + Vector decls; + Int stageImageUniforms = 0; + 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 ? ParseArrayExtent(match[5].str()) : 1; + decl.declStart = static_cast(match.position(0)); + decl.declLength = match[0].str().size(); + stageImageUniforms += decl.elementCount > 0 ? decl.elementCount : 1; + decls.push_back(Move(decl)); + } + + Vector edits; + Vector 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(decl->elementCount) != plan.units.size()) { + decline("the emitted array extent disagrees with the reflected element count"); + continue; + } + + Int minUnit = plan.units[0]; + Int maxUnit = plan.units[0]; + Bool consecutive = true; + for (SizeT element = 0; element < plan.units.size(); ++element) { + const Int unit = plan.units[element]; + if (unit < 0) { + minUnit = -1; + break; + } + if (unit != plan.units[0] + static_cast(element)) consecutive = false; + minUnit = std::min(minUnit, unit); + maxUnit = std::max(maxUnit, unit); + } + if (minUnit < 0) { + 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; + + const Int spanSize = maxUnit - minUnit + 1; + // The array has to COVER every unit from the lowest to the highest, because ESSL + // hands an array's elements consecutive units and nothing else can move them. + // The elements in between are declared and never accessed; what they cost is + // image-uniform budget, so that is what is checked. + if (stageImageUniformBudget > 0 && + stageImageUniforms - decl->elementCount + spanSize > stageImageUniformBudget) { + decline("the units are spread too far apart to cover within this stage's " + "GL_MAX_*_IMAGE_UNIFORMS"); + continue; + } + + // Every use has to be `name[...]`, or the element index has nowhere to be + // rewritten and the array cannot be widened underneath it. + struct Subscript { + SizeT open; + SizeT close; + }; + Vector subscripts; + Bool everyUseIsSubscripted = true; + 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] != '[') { + everyUseIsSubscripted = false; + 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) { + everyUseIsSubscripted = false; + break; + } + subscripts.push_back({open, scan}); + } + if (!everyUseIsSubscripted) { + decline("it is reached by something other than a subscript, so there is no element " + "index to rewrite"); + continue; + } + + const String mapName = + MakeImageAliasName(IMAGE_UNIT_MAP_PREFIX, plan.name, glslCode, takenNames); + takenNames.push_back(mapName); + + // The widened declaration, rebased on the lowest unit... + String layout = decl->layout; + const String bindingText = "binding = " + std::to_string(minUnit); + if (std::regex_search(layout, bindingValueRegex)) { + layout = std::regex_replace(layout, bindingValueRegex, bindingText); + } else { + layout = bindingText + (layout.empty() ? String() : ", " + layout); + } + String replacement = "layout(" + layout + ") uniform "; + if (!decl->qualifiers.empty()) { + replacement += decl->qualifiers; + replacement += ' '; + } + replacement += decl->type + " " + plan.name + "[" + std::to_string(spanSize) + "];"; + // ...and the table that turns the application's element index into the offset of + // the unit that element was actually assigned. A const array is a constant + // expression when it is indexed by one, so a shader whose subscripts are literals + // keeps constant subscripts; and when the subscript is a loop counter the lookup + // stays dynamically uniform, which is what GLSL ES 3.20 requires of an image + // array index. + const SizeT elementCount = plan.units.size(); + replacement += "\nconst highp int " + mapName + "[" + std::to_string(elementCount) + "] = int[" + + std::to_string(elementCount) + "]("; + for (SizeT element = 0; element < elementCount; ++element) { + if (element != 0) replacement += ", "; + replacement += std::to_string(plan.units[element] - minUnit); + } + replacement += ");"; + edits.push_back({decl->declStart, decl->declLength, Move(replacement)}); + + // `name[EXPR]` -> `name[[EXPR]]`, by insertion, so EXPR itself is untouched + // however it is spelled. + for (const Subscript& subscript : subscripts) { + edits.push_back({subscript.open + 1, 0, mapName + "["}); + edits.push_back({subscript.close, 0, "]"}); + } + } + if (edits.empty()) return glslCode; + + // Back to front, so an earlier edit's offsets stay valid. Two inserts never share an + // offset: a subscript's opening and closing brackets are distinct positions and a + // declaration is replaced whole. + 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, GLenum shaderType, Uint* outSplitCount) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 067827b7..e637d6c3 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -235,6 +235,51 @@ namespace MobileGL::MG_Backend::DirectGLES { // stops being safe to edit by hand. String BakeImageFormatQualifiers(String glslCode, const UnorderedMap& esslFormatByUniformName); String RemoveLayoutBinding(const String& glslCode); + // Prefix of the const element->unit-offset table RemapImageArrayElementUnits declares + // next to a widened image array; the suffix is the array's own name. + constexpr const char* IMAGE_UNIT_MAP_PREFIX = "mg_imageUnitMap_"; + // 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 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. + // + // Repaired by WIDENING the array to cover every unit from the lowest it needs to the + // highest, rebasing its binding on the lowest, and routing every subscript through a + // `const highp int` table of per-element offsets. The elements in between are declared + // and never accessed. A const array indexed by a constant expression IS one, so literal + // subscripts stay literal; and a table lookup on a dynamically uniform index is itself + // dynamically uniform, which is what GLSL ES 3.20 asks of an image array index - so this + // works whether SPIRV-Cross unrolled the application's loop over the array or not. That + // is the reason for the table rather than one scalar declaration per element: scalars + // cannot be selected by a non-constant index at all. + // + // Declines - leaving the array exactly as it was, and naming it in `outDeclined` for the + // caller to report - when the span will not fit in `stageImageUniformBudget` + // (GL_MAX__IMAGE_UNIFORMS; <= 0 means "cannot say", and then it is not enforced), + // when the emitted extent disagrees with the reflection, or when the array is reached by + // anything other than a subscript. 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 both halves of a split inherit the widened extent and + // the rebased binding) 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& plans, + Int stageImageUniformBudget, Vector* outDeclined = nullptr); // Prefix of the writeonly half a read+write image uniform is split into (see // SplitReadWriteImageUniforms); the suffix is the image's own (already stage-tagged) name. constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_"; diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp index d78426cc..fcdb3cf0 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp @@ -127,14 +127,24 @@ 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 WIDENING the array to cover every unit + // from the lowest it needs to the highest and routing each subscript through a const + // offset table. What that costs is image-uniform budget: units 0..6 need SEVEN + // fragment image uniforms where the application declared four. So the gate is the + // budget, not the backend - a driver that cannot afford the widening declines it + // (and says so at ERROR level) rather than addressing the wrong units silently. + // DirectVulkan has no such constraint and needs no widening 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) { + } + // The widest span either of the two fragment programs below needs: 0..6 and 1..7. + return maxFragmentImageUniforms >= 7; + } // The scenarios below need image load/store at all; a driver without it should skip // rather than fail. @@ -164,7 +174,8 @@ 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 7 fragment image uniforms: the widening that covers a " + "non-consecutive image array does not fit"; } HeadlessGL& gl = Gl(); @@ -283,8 +294,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(); diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index 5d707419..c27f2f9e 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -19,8 +19,11 @@ using namespace MobileGL; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BakeImageFormatQualifiers; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_STAGE_ALIAS_PREFIX; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_UNIT_MAP_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageStageAliasPrefix; +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; @@ -47,6 +50,7 @@ namespace { // The writeonly half is minted from the ALREADY stage-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(StageAlias(name)); } + String UnitMap(const String& name) { return String(IMAGE_UNIT_MAP_PREFIX) + name; } } // namespace // The bug the pass exists for. SPIRV-Cross speculatively marks every storage image @@ -502,6 +506,170 @@ TEST(SplitReadWriteImageUniformsTest, EveryStageTagIsDistinct) { } } +// --------------------------------------------------------------------------------------- +// 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, written + // through a loop counter (which is how SPIRV-Cross emits the conformance case's + // `for (int i = 0; i < g_image.length(); ++i)` when it does not unroll it). + const char* const kSsoImageArrayFS = 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)); + } +} +)"; + + ImageArrayUnitPlan Plan(const String& name, const Vector& 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 is widened to cover +// units 0..6 and every subscript is routed through the offset table. Before this, the single +// stamped binding sent the four elements to units 0, 1, 2, 3. +TEST(RemapImageArrayElementUnitsTest, NonConsecutiveUnitsWidenTheArrayAndRouteEverySubscript) { + Vector declined; + const String out = + RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, 8, &declined); + + EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]); + // Seven elements from binding 0, i.e. units 0..6 - the span the four assigned units need. + EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[7];")) << out; + EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[4] = int[4](0, 2, 4, 6);")) << out; + EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("g_image") + "[i]], ivec2(gl_FragCoord.xy)")) << out; + // The original four-element extent is gone; nothing may still address units 0,1,2,3. + EXPECT_FALSE(Contains(out, "image2D g_image[4];")) << out; +} + +// The other program of the same conformance case: units 1, 3, 5, 7, so the binding rebases onto +// the LOWEST unit rather than staying on element [0]'s. +TEST(RemapImageArrayElementUnitsTest, TheBindingRebasesOntoTheLowestUnitInTheSpan) { + 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})}, 8); + EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = 1) uniform writeonly highp image2D g_image[7];")) << out; + // Offsets from the new base, in the application's element order - the order is what carries + // the assignment, so it must NOT be sorted. + EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[4] = int[4](2, 0, 6, 4);")) << out; + // A literal subscript stays a constant expression: a const array indexed by one is one. + EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("g_image") + "[0]], ivec2(0)")) << out; + EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("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})}, 8), 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})}, 8), source); +} + +// Widening costs image-uniform budget, and a stage that cannot afford it must be told so rather +// than silently addressing the wrong units - the exact silence this whole pass exists to end. +TEST(RemapImageArrayElementUnitsTest, ASpanThatExceedsTheStageBudgetIsDeclinedAndNamed) { + Vector declined; + const String out = + RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, 4, &declined); + EXPECT_EQ(out, String(kSsoImageArrayFS)) << out; + ASSERT_EQ(declined.size(), 1u); + EXPECT_TRUE(Contains(declined[0], "g_image")) << declined[0]; + + // A budget of "cannot say" (the ES side reports no limit for this stage) must not be read as + // a budget of zero - that would decline every array on a driver that simply does not answer. + Vector unknownBudget; + const String repaired = + RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, -1, &unknownBudget); + EXPECT_TRUE(unknownBudget.empty()); + EXPECT_TRUE(Contains(repaired, "image2D g_image[7];")) << repaired; +} + +// A use the pass cannot see a subscript on has no element index to rewrite, so widening the +// array underneath it would change which unit it reaches. 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 declined; + EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 5})}, 8, &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 declined; + EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, 16, &declined), source); + ASSERT_EQ(declined.size(), 1u); +} + +// The two passes that run after it have to see the widened declaration and keep its binding: an +// ES image unit cannot be assigned through the API, so the qualifier is the only mechanism there +// is, and a read+write array is split into two declarations that must BOTH be the widened one. +TEST(RemapImageArrayElementUnitsTest, TheWidenedArraySurvivesTheLaterImagePasses) { + 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})}, 8); + out = SplitReadWriteImageUniforms(out, kStage); + out = RemoveLayoutBinding(out); + + // Both halves, both widened to the three units 4..6, and both still bound. + EXPECT_EQ(CountOf(out, "binding = 4"), 2u) << out; + EXPECT_TRUE(Contains(out, "image2D " + StageAlias("g_image") + "[3];")) << out; + EXPECT_TRUE(Contains(out, "image2D " + SplitWriteAlias("g_image") + "[3];")) << out; + // The offset table is untouched by the rename - it is not an image uniform - and both halves + // still route their subscripts through it. + EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[2] = int[2](0, 2);")) << out; + EXPECT_TRUE(Contains(out, SplitWriteAlias("g_image") + "[" + UnitMap("g_image") + "[1]]")) << out; + EXPECT_TRUE(Contains(out, StageAlias("g_image") + "[" + UnitMap("g_image") + "[0]]")) << out; +} + // --------------------------------------------------------------------------------------- // RetargetTextureBufferExtension // From 21a4c8aa95da2b146aec1b1e8ce4d1bc3346b7de Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 04:57:52 -0400 Subject: [PATCH 10/23] [Fix, Test] (ShaderTranspiler, DirectGLES): widen the offset and gradients of a 1D sampler lookup for ESSL --- CMakeLists.txt | 1 + MobileGL/MG_Backend/DirectGLES/Managers.cpp | 21 + MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 285 ++++++++ .../ShaderTranspiler/ShaderCompiler.cpp | 34 + .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 13 + .../SpirvPasses/Lower1DSampledImagesPass.cpp | 652 ++++++++++++++++++ .../SpirvPasses/Lower1DSampledImagesPass.h | 116 ++++ .../ShaderTranspiler/TranslationCache.h | 6 +- 8 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bb93da1..4c357c43 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,6 +293,7 @@ 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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index c5abeea7..9220ac87 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5665,6 +5665,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 LegalizeStorageBlockArrayIndexingForEssl 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 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 diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 4a680bd0..c3c08fe7 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -3651,6 +3652,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& 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 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 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 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 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 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 spirv; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv)); + ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv)) + << DisassembleSpirv(spirv); + + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector 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 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 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 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 spirv; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv)); + ASSERT_EQ(Count1DSampledImageTypes(spirv), 2u); + + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector 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 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 spirv; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv)); + ASSERT_EQ(Count1DSampledImageTypes(spirv), 1u); + + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector 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 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 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 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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index bd15b5aa..1e5c5b70 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -33,6 +33,7 @@ #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" @@ -1128,6 +1129,39 @@ namespace MobileGL { return RunOptimizerChecked("Lower1DArrayImagesForEssl", optimizer, inputBinary, outputBinary, true, enableSpirvValidation); } + bool ShaderCompiler::Lower1DSampledImagesForEssl(const Vector& inputBinary, + Vector& 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& inputBinary, Vector& outputBinary, const bool enableSpirvValidation) { using namespace spvtools; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 5f844d6f..bf92aafd 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -209,6 +209,19 @@ namespace MobileGL { static bool Lower1DArrayImagesForEssl(const Vector& inputBinary, Vector& 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& inputBinary, + Vector& 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` diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp new file mode 100644 index 00000000..7c8e406c --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp @@ -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 +#include + +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(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(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 + 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 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& binary) { + if (binary.empty()) { + return false; + } + std::unique_ptr 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 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(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(capability.GetSingleWordInOperand(0)); + if (value == spv::Capability::Sampled1D) { + capability.SetInOperand(0, {static_cast(spv::Capability::Shader)}); + } + } + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken Lower1DSampledImagesPass::CreateLower1DSampledImagesPass() { + return spvtools::Optimizer::PassToken( + spvtools::MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h new file mode 100644 index 00000000..6b16a85a --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h @@ -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 + +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& binary); + + static spvtools::Optimizer::PassToken CreateLower1DSampledImagesPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index b371e8f0..45f89a22 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -566,9 +566,9 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // // Unconditional passes take no input but the module and so need no key material: // StripUboMemberRelaxedPrecision, LowerRectImages, Lower1DArrayImages, - // LegalizeStorageBlockArrayIndexing and FlattenAtomicCounterBlockOffsets. Each self-gates - // on the module's own content and is armed by nothing, so the SPIR-V already in this key - // covers them completely. + // Lower1DSampledImages, LegalizeStorageBlockArrayIndexing and + // FlattenAtomicCounterBlockOffsets. Each self-gates on the module's own content and is + // armed by nothing, so the SPIR-V already in this key covers them completely. // // THE TEST FOR THAT CLAIM IS NOT THE SIGNATURE. LowerViewportIndexForEssl is equally // module-only to look at, yet SupportsViewportArray is in this key because that bit ARMS From d9def5c1bbc25d7837b76213170fbb9f93fc1220 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 05:13:58 -0400 Subject: [PATCH 11/23] [Fix, Test] (GLImpl, GLState): give a storage block with no binding qualifier GL's default binding of zero --- .../GLState/ProgramState/ProgramLinkTask.cpp | 65 ++++++++++++ .../GLState/ProgramState/ProgramLinkTask.h | 5 + .../GLState/ProgramState/ProgramObject.cpp | 4 + .../GLState/ProgramState/ProgramObject.h | 13 +++ .../ProgramState/ShaderCompileTask.cpp | 8 ++ .../GLState/ProgramState/ShaderCompileTask.h | 3 + .../GLState/ProgramState/ShaderObject.h | 7 ++ .../ProgramState/ShaderPreprocessCache.h | 5 + MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 99 +++++++++++++++++++ .../ShaderSourceProcessor.cpp | 83 ++++++++++++++++ .../ShaderTranspiler/ShaderSourceProcessor.h | 20 ++++ 11 files changed, 312 insertions(+) diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 2fe4cf96..69dbfc2a 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -828,6 +828,13 @@ namespace MobileGL::MG_State::GLState { for (const auto& [name, binding] : compiled.explicitOpaqueBindings) { artifacts.explicitOpaqueUniformBindings[name] = binding; } + // Storage blocks declared with NO layout(binding = N), which GL puts on binding 0. + // A UNION across stages, unlike the maps above: a block that any stage declared + // unqualified is unqualified, because GLSL requires every stage declaring the same + // block to declare it identically - so the stages cannot disagree, and a stage whose + // grammar the scanner did not recognise simply contributes nothing. + artifacts.storageBlocksWithoutBinding.insert(compiled.storageBlocksWithoutBinding.begin(), + compiled.storageBlocksWithoutBinding.end()); } } @@ -1505,6 +1512,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 +1561,63 @@ namespace MobileGL::MG_State::GLState { artifacts.pipeInputReflection.size(), artifacts.pipeOutputReflection.size()); } + // GL 4.3 core 7.8: a shader storage block declared without a layout(binding = N) qualifier + // has a buffer binding of ZERO. MobileGL could not report that, because by the time this + // reflection is built the number in the block's qualifier is one glslang INVENTED. + // + // Every shader is parsed as a Vulkan client, so glslang's IO mapper takes the `set = openGl + // ? resource : ent.newSet` branch with openGl == 0 (iomapper.cpp resolveBinding) - i.e. it + // allocates out of ONE flat binding space shared by every sampler, image, uniform block, + // storage block and the synthesized MGL_GLOBAL_UBO - and then writes the result back into + // the type's qualifier (iomapper.cpp, `base->getWritableType().getQualifier().layoutBinding = + // at->second.newBinding`). getBinding() therefore answers with the auto-assigned slot and + // cannot be distinguished from a declared one. An unqualified block lands on 0 only when + // nothing else in the program claimed 0 first, which is why a lone storage block in a + // trivial shader looked correct and KHR-GL43.compute_shader.resource-ubo - whose shader also + // declares twelve uniform blocks - wrote everything to a binding nothing was bound at. + // + // THE FLAT SPACE IS LEFT ALONE. It is load-bearing: DirectVulkan indexes bindingKinds[], + // uniformBlockIndexByBinding[] and storageBlockIndexByBinding[] by that one number and + // asserts when two resources collide on it, so forcing the SPIR-V decoration to 0 would + // collide an unqualified block with the global UBO and take working programs down. What is + // repaired is the GL-VISIBLE binding, through the record GL already has for exactly this - + // the same per-name map glShaderStorageBlockBinding writes, which both backends already + // consult (ProgramInterface's GL_BUFFER_BINDING, DirectGLES's SPIRV-Cross binding rewrite, + // DirectVulkan's GetShaderStorageBlockBinding). Seeding it here means the default and a + // later rebind travel the same path, and basic-noBindingLayout - which rebinds all three of + // its unqualified blocks - keeps working because a rebind simply overwrites the seed. + // + // Seeded INSIDE `artifacts`, so an L1 translation-cache hit that republishes the artifacts + // wholesale carries it too; a seed applied outside them would silently vanish on a hit. + // + // Only blocks the lexical scanner recognised in full AND recognised as unqualified are + // seeded. A block whose grammar it did not understand keeps today's behaviour rather than + // being defaulted on a guess - the shape of the input decides, never an assumption about it. + // + // THE COLLISION IS DELIBERATE, and it is GL's. Several unqualified blocks all default to 0 + // and alias there until the application rebinds them; a real GL driver does the same, which + // is why every program that has more than one either rebinds or uses one of them. + // basic-noBindingLayout is that regression test - it rebinds all three of its blocks + // immediately after linking, and the DirectGLES transpile is lazy (first use, not link), so + // the ESSL it eventually emits already carries the rebound 0/1/2 and never the aliased seed. + // What this replaces was not a safer arrangement, only an accidental one: the three blocks + // got glslang's 0/1/2 and an application that rebound them to anything else still wrote to + // the wrong buffers. + void ProgramLinkTask::SeedDefaultStorageBlockBindings() { + if (artifacts.storageBlocksWithoutBinding.empty()) return; + for (const ProgramObject::BlockReflection& block : artifacts.blockReflection) { + if (!block.type.isBuffer) continue; + // An instance array reflects as "B[0]", "B[1]", ... and each element is its own GL + // resource with its own binding; the scanner keys on the block TYPE name, so the + // subscript is stripped before the lookup. GL gives element k of an unqualified + // array binding 0 + k, the same base + element rule a declared binding follows. + const String base = StripArrayElementSuffix(block.name); + if (!artifacts.storageBlocksWithoutBinding.contains(base)) continue; + // First writer wins: never overwrite a binding the application has already chosen. + artifacts.shaderStorageBlockBinding.emplace(block.name, BlockArrayElement(block.name)); + } + } + Bool ProgramLinkTask::ValidateFragmentOutputLocations() { if (!artifacts.program) return false; // The pipe-output list is the output interface of the program's LAST stage. Only a diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h index fe4e6bdc..61b75424 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -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); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 67dee41e..e5da0fea 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -344,6 +344,10 @@ namespace MobileGL::MG_State::GLState { artifacts.uniformBlockIndexByName.clear(); artifacts.uniformBlockBinding.clear(); artifacts.shaderStorageBlockBinding.clear(); + // Cleared with it: the seed above is re-derived from the newly attached shaders on every + // link, so a stale set would otherwise default a block the new sources do declare a + // binding for. + artifacts.storageBlocksWithoutBinding.clear(); artifacts.attribs.clear(); artifacts.attribTypes.clear(); artifacts.activeUniformCount = 0; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 341154bc..ce58c551 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -1125,7 +1125,20 @@ namespace MobileGL::MG_State::GLState { Vector 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 shaderStorageBlockBinding; + // Block type names of the storage blocks the program's shaders declared with NO + // layout(binding = N), merged across stages by MergeShaderSideChannels. Input to the + // seeding above; see ExtractStorageBlocksWithoutExplicitBinding for why it has to be + // captured lexically. + std::set storageBlocksWithoutBinding; Uint activeUniformCount = 0; Uint maxUniformLocation = 0; diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp index e950b0ed..69d403d6 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp @@ -211,6 +211,12 @@ namespace { // ProgramObject::DoReflection. result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource); result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource); + // The third side-channel, and the one that restores a GL DEFAULT rather than an + // application-declared value: a storage block with no layout(binding = N) has buffer + // binding 0 in GL, and by the time the reflection is built glslang's IO mapper has + // already invented one and written it into the qualifier. + result.storageBlocksWithoutBinding = + ExtractStorageBlocksWithoutExplicitBinding(result.preprocessedSource); result.outcome = ShaderPreprocessOutcome::Preprocessed; return result; } @@ -355,6 +361,7 @@ namespace MobileGL::MG_State::GLState { artifacts.preprocessedSource = shared.preprocessedSource; artifacts.explicitUniformLocations = shared.explicitUniformLocations; artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings; + artifacts.storageBlocksWithoutBinding = shared.storageBlocksWithoutBinding; artifacts.infoLog.clear(); if (shouldPopulateCache) { cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh)); @@ -381,6 +388,7 @@ namespace MobileGL::MG_State::GLState { fresh->infoLog = artifacts.infoLog; fresh->explicitUniformLocations.clear(); fresh->explicitOpaqueBindings.clear(); + fresh->storageBlocksWithoutBinding.clear(); cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh)); } } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h index ae641eb5..2c819010 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h @@ -62,6 +62,9 @@ namespace MobileGL::MG_State::GLState { String preprocessedSource; UnorderedMap explicitUniformLocations; UnorderedMap explicitOpaqueBindings; + // Block type names of this stage's shader storage blocks that declared no + // layout(binding = N), i.e. the ones GL puts on binding 0. + std::set storageBlocksWithoutBinding; String infoLog; Bool compileStatus = false; }; diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h index ac8d3834..9901149f 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h @@ -112,6 +112,13 @@ namespace MobileGL { const UnorderedMap& GetExplicitOpaqueBindings() const { return Compiled().explicitOpaqueBindings; } + // Block type names of this shader's storage blocks that declared NO + // layout(binding = N), captured lexically because glslang's IO mapper invents one + // and overwrites the qualifier before anything can ask (see + // ExtractStorageBlocksWithoutExplicitBinding). + const std::set& GetStorageBlocksWithoutBinding() const { + return Compiled().storageBlocksWithoutBinding; + } Bool GetCompileStatus() const { return Compiled().compileStatus; } Bool GetDeleteStatus() const { return m_deleteStatus; } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h b/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h index 55ec4465..52a38155 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h @@ -46,6 +46,11 @@ namespace MobileGL::MG_State::GLState { String preprocessedSource; UnorderedMap explicitUniformLocations; UnorderedMap explicitOpaqueBindings; + // Block type names of the shader storage blocks declared here with NO + // layout(binding = N). GL gives such a block binding 0; nothing downstream can still + // tell, because glslang's IO mapper auto-assigns one and writes it into the qualifier. + // See ExtractStorageBlocksWithoutExplicitBinding. + std::set storageBlocksWithoutBinding; // The compile info log to publish; empty when outcome == Preprocessed. String infoLog; diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index c3c08fe7..9b2799cc 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -4538,3 +4538,102 @@ void main() {} ASSERT_EQ(bindings.count("suffixedUnit"), 1u); EXPECT_EQ(bindings.at("suffixedUnit"), 1u); } + +// GL 4.3 core 7.8 puts a storage block with no layout(binding = N) on binding ZERO. Nothing +// downstream can still tell which blocks those are, because glslang's IO mapper auto-assigns a +// binding out of one flat space and writes it into the qualifier - so the reflection reports the +// invention. This scanner is the only surviving record, and it reports POSITIVELY: a block is +// named only when it was recognised in full AND recognised as unqualified. +TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingNamesOnlyUnqualifiedBlocks) { + using namespace MG_Util::ShaderTranspiler; + + // KHR-GL43.compute_shader.resource-ubo's own shape: an unqualified storage block alongside + // the uniform blocks whose presence is what pushes it off binding 0 today. + const String source = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std140) uniform InputBuffer { vec4 data[4]; } g_in_buffer[12]; +layout(std430) buffer OutputBuffer { vec4 data0[4]; } g_out_buffer; +layout(std430, binding = 3) buffer BoundBlock { vec4 data1[4]; } g_bound; +layout(binding = 5, std430) buffer BoundFirst { vec4 data2[4]; } g_bound_first; +void main() { g_out_buffer.data0[0] = g_in_buffer[0].data[0]; } +)"; + + const std::set unqualified = ExtractStorageBlocksWithoutExplicitBinding(source); + EXPECT_EQ(unqualified.count("OutputBuffer"), 1u) + << "the block the test binds at 0 with glBindBufferBase must be recognised"; + EXPECT_EQ(unqualified.count("BoundBlock"), 0u) + << "a declared binding must never be defaulted away"; + EXPECT_EQ(unqualified.count("BoundFirst"), 0u) + << "the binding may appear anywhere in the layout list, not only last"; + // A UNIFORM block is a different binding space with its own glUniformBlockBinding path, and + // its default is already handled where uniformBlockBinding is seeded. Naming it here would + // make the seeder default a resource it does not own. + EXPECT_EQ(unqualified.count("InputBuffer"), 0u) << "uniform blocks are out of scope"; +} + +// The scanner must not mistake a member qualifier, a buffer-typed sampler, or the +// "layout(...) buffer;" default-qualifier form for a block declaration - and must record nothing +// at all for grammar it does not fully recognise, so that anything surprising keeps today's +// behaviour instead of being defaulted on a guess. +TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingIgnoresNonBlockBufferTokens) { + using namespace MG_Util::ShaderTranspiler; + + const String source = R"(#version 430 core +layout(local_size_x = 1) in; +uniform samplerBuffer texelSampler; +layout(std430) buffer; +layout(std430) buffer Real { vec4 v[4]; } realInstance; +void main() { realInstance.v[0] = texelFetch(texelSampler, 0); } +)"; + + const std::set unqualified = ExtractStorageBlocksWithoutExplicitBinding(source); + EXPECT_EQ(unqualified.count("Real"), 1u); + // samplerBuffer is one identifier token, so it can never match the `buffer` keyword; and the + // default-qualifier form declares no block, so there is no name to record. + EXPECT_EQ(unqualified.size(), 1u) + << "only the one real block declaration may be recorded"; +} + +// The dangerous direction, because a false positive here DEFAULTS AWAY a binding the shader +// really declared. Memory qualifiers may sit between the layout list and the `buffer` keyword in +// either order, and the binding has to survive them. +TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingKeepsBindingsAcrossMemoryQualifiers) { + using namespace MG_Util::ShaderTranspiler; + + const String source = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 1) coherent restrict buffer AfterLayout { uint a; } afterLayout; +readonly layout(std430, binding = 2) buffer BeforeLayout { uint b; } beforeLayout; +writeonly buffer NoBindingAtAll { uint c; } noBinding; +void main() { noBinding.c = afterLayout.a + beforeLayout.b; } +)"; + + const std::set unqualified = ExtractStorageBlocksWithoutExplicitBinding(source); + EXPECT_EQ(unqualified.count("AfterLayout"), 0u) + << "coherent/restrict must not break the qualifier run and lose the binding"; + EXPECT_EQ(unqualified.count("BeforeLayout"), 0u) + << "a qualifier may precede the layout list too"; + EXPECT_EQ(unqualified.count("NoBindingAtAll"), 1u) + << "a memory-qualified block with no binding is still an unqualified block"; +} + +// This scans preprocessor-visible text, so a block can be declared twice - once with a binding +// and once without. Reporting it as unqualified would default away a binding the active +// declaration carries, so any name seen WITH a binding is dropped outright. +TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingDropsNamesSeenBothWays) { + using namespace MG_Util::ShaderTranspiler; + + const String source = R"(#version 430 core +layout(local_size_x = 1) in; +layout(std430, binding = 4) buffer Ambiguous { uint a; } bound; +layout(std430) buffer Ambiguous { uint a; } unbound; +layout(std430) buffer Clear { uint b; } clearInstance; +void main() { clearInstance.b = 0u; } +)"; + + const std::set unqualified = ExtractStorageBlocksWithoutExplicitBinding(source); + EXPECT_EQ(unqualified.count("Ambiguous"), 0u) + << "seen both ways is a doubt, and a doubt must not become a default"; + EXPECT_EQ(unqualified.count("Clear"), 1u) + << "the unambiguous block alongside it is still recognised"; +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp index 6b1502f3..fc67c811 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp @@ -1548,6 +1548,89 @@ namespace MobileGL { } } // namespace + std::set ExtractStorageBlocksWithoutExplicitBinding(const String& source) { + std::set names; + // Fast path: no storage block, nothing to record. `buffer` as a whole token is + // what declares one; samplerBuffer/imageBuffer/textureBuffer tokenize as single + // identifiers and so cannot match below, but this substring test is only a + // cheap pre-filter and is allowed to be generous. + if (source.find("buffer") == String::npos) return names; + + const Vector tokens = TokenizeCode(source); + const SizeT count = tokens.size(); + Int braceDepth = 0; + // Block names seen WITH a binding. Subtracted at the end so a name that is + // declared unqualified in one place and qualified in another is never reported: + // this scans preprocessor-visible text, so both arms of a #if can be present, + // and defaulting a block the active arm binds explicitly would be a regression. + // A name is dropped whenever there is any doubt, never kept. + std::set qualified; + // The binding the qualifier run currently being scanned declared, -1 for none. + // Several layout(...) lists may precede one declaration and the later one wins - + // the same accumulate-then-consume shape FindShaderStorageBindingViolation uses. + long long binding = -1; + long long literal = 0; + for (SizeT pos = 0; pos < count; ++pos) { + const String& text = tokens[pos].text; + if (text == "{") { + ++braceDepth; + binding = -1; + continue; + } + if (text == "}") { + if (braceDepth > 0) --braceDepth; + continue; + } + // Only depth-0 declarations are block declarations; `buffer` inside a block + // body or a function is a member qualifier or an identifier. + if (braceDepth != 0) continue; + + if (text == "layout" && pos + 1 < count && tokens[pos + 1].text == "(") { + SizeT j = pos + 2; + Int parenDepth = 1; + while (j < count && parenDepth > 0) { + const String& layoutToken = tokens[j].text; + if (layoutToken == "(") { + ++parenDepth; + } else if (layoutToken == ")") { + --parenDepth; + } else if (parenDepth == 1 && layoutToken == "binding" && j + 2 < count && + tokens[j + 1].text == "=" && + ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) { + binding = std::min(literal, static_cast(INT_MAX / 2)); + j += 2; + } + ++j; + } + pos = j - 1; + continue; + } + + if (text == "buffer") { + // Recorded ONLY for the fully recognised shape: a block type name + // followed by the body's '{'. The "layout(...) buffer;" + // default-qualifier form declares no block and has no name to key on, + // and anything else here is grammar this scanner does not judge - both + // fall through and keep today's behaviour. + if (pos + 2 < count && IsIdentifierToken(tokens[pos + 1]) && + tokens[pos + 2].text == "{") { + (binding < 0 ? names : qualified).insert(tokens[pos + 1].text); + } + binding = -1; + continue; + } + + // Qualifiers may sit between the layout list and the `buffer` keyword; + // anything else ends the run, so a binding never leaks onto an unrelated + // declaration - and, just as importantly, the ABSENCE of one never does. + if (!IsNonLayoutQualifierKeyword(text)) binding = -1; + } + for (const String& name : qualified) { + names.erase(name); + } + return names; + } + std::optional FindShaderStorageBindingViolation(const String& source, Int maxBindings) { // A backend that advertises nothing has no ceiling to enforce. if (maxBindings <= 0) return std::nullopt; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.h index e7973a25..3b45a3ac 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.h @@ -7,6 +7,8 @@ // End of Source File Header #pragma once +#include + #include #include #include @@ -65,6 +67,24 @@ namespace MobileGL { // grammar discipline as ExtractExplicitUniformLocations). UnorderedMap ExtractExplicitOpaqueBindings(const String& source); + // The BLOCK TYPE NAMES of the shader storage blocks this source declares WITHOUT a + // layout(binding = N) qualifier. GL 4.3 core 7.8 gives such a block a buffer binding + // of ZERO, which the application may then move with glShaderStorageBlockBinding. + // + // Exists because nothing downstream can still tell. Every shader is parsed as a + // Vulkan client, so glslang's IO mapper allocates a binding for the block out of one + // flat space shared with every sampler, image and uniform block in the program + // (iomapper.cpp resolveBinding: `set = openGl ? resource : ent.newSet`, and openGl is + // 0 here) - and then WRITES IT BACK into the type's qualifier, so the reflection + // reports an auto-assigned number as if the shader had declared it. An unqualified + // block therefore lands on 0 only when nothing else claimed 0 first. + // + // Reported POSITIVELY - only blocks the scanner recognised in full, and recognised as + // carrying no binding - so anything outside its narrow grammar is left to the + // existing behaviour rather than defaulted on a guess. Same discipline as + // ExtractExplicitOpaqueBindings. + std::set ExtractStorageBlocksWithoutExplicitBinding(const String& source); + // A shader storage block whose layout(binding = N) reaches or passes // GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS is a compile-time error in GL 4.3 core 4.4.5, // and an arrayed block instance takes CONSECUTIVE points, so the last element is what From 50da7de737f51e8a7d7c98591d7c2eb82b61aaea Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 05:23:57 -0400 Subject: [PATCH 12/23] [Fix, Test] (DirectGLES, ShaderTranspiler): synthesize the pass-through tessellation control stage ES requires --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 11 +- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 157 ++++++++++++++++++ MobileGL/MG_Backend/DirectGLES/Managers.h | 23 +++ MobileGL/MG_Backend/DirectGLES/Utils.cpp | 76 +++++++++ MobileGL/MG_Backend/DirectGLES/Utils.h | 44 +++++ .../Backend/DirectGLES/EsslShaderPassTest.cpp | 83 +++++++++ .../ShaderTranspiler/ShaderCompiler.cpp | 37 +++++ .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 13 ++ 8 files changed, 443 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index a09886f9..c3cc734c 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -2393,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(MG_State::pGLContext->GetPatchVertices()))) { twin->SyncToBackend(currentProgram); } g_currentDrawFrontendProgram = currentProgram.get(); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 9220ac87..cf8a4134 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5864,6 +5864,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>& 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(patchVertices); + + if (tessEvalShaderIndex < 0 || + static_cast(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(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 log(static_cast(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& stateProgramObject) { #ifdef TRACY_ENABLE @@ -5909,6 +6026,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). @@ -6047,6 +6168,27 @@ namespace MobileGL::MG_Backend::DirectGLES { return candidate; }; + // Desktop GL makes the tessellation CONTROL stage optional; OpenGL ES 3.2 rejects a + // program that has an evaluation stage without one, with an empty info log. When that + // is this program's shape, one is synthesized below - and it has to be spelled to + // MATCH the two stages it sits between, so their emitted ESSL is kept here as it is + // produced. Empty for every program that has a control stage of its own, which is + // all but a handful. + Bool hasTessEvalStage = false; + Bool hasTessControlStage = false; + Int tessEvalShaderIndex = -1; + String vertexStageEssl; + String tessEvalStageEssl; + for (int index = 0; index < attachedShaders.size(); ++index) { + const auto stage = attachedShaders[index]->GetShaderStage(); + if (stage == ShaderStage::TessControl) hasTessControlStage = true; + if (stage == ShaderStage::TessEval) { + hasTessEvalStage = true; + tessEvalShaderIndex = index; + } + } + const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage; + for (int index = 0; index < attachedShaders.size(); ++index) { auto& shader = attachedShaders[index]; GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(shader->GetShaderStage()); @@ -6374,9 +6516,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. diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index a0b54dd2..0cea95e9 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -1222,6 +1222,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // counter sync at one empty-vector test. const Vector& 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& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; } @@ -1270,6 +1279,16 @@ namespace MobileGL::MG_Backend::DirectGLES { private: void CacheResourceLocations(const SharedPtr& 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>& shaderSpirvs, const String& vertexStageEssl, + const String& tessEvalStageEssl); + // One stage's SPIR-V through the DirectGLES pass chain and SPIRV-Cross, producing // the raw emitted ESSL and the interface blocks this stage's XFB flattening // rewrote. This is the segment the L2 shader-translation memo keys on, so every @@ -1307,6 +1326,10 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_shaderStorageBlockBindingSignature = 0; Vector 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; diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index d805ef9e..aaffdcb2 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -746,6 +746,82 @@ namespace MobileGL::MG_Backend::DirectGLES { return result; } + std::optional 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(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(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(c)) || c == '_'; diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index e637d6c3..6f3a2d18 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -280,6 +280,50 @@ namespace MobileGL::MG_Backend::DirectGLES { // it reads need no entry in BuildEsslTranslationKey. String RemapImageArrayElementUnits(const String& glslCode, const Vector& plans, Int stageImageUniformBudget, Vector* 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 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 (already stage-tagged) name. constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_"; diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index c27f2f9e..7e4acfb1 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -17,6 +17,8 @@ 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_STAGE_ALIAS_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_UNIT_MAP_PREFIX; @@ -1015,3 +1017,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; +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 1e5c5b70..df9301ee 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -598,6 +598,43 @@ namespace MobileGL { return false; } + Bool ShaderCompiler::ModuleReadsLocatedInput(const Vector& spirv) { + if (spirv.empty()) { + return false; + } + std::unique_ptr 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(variable.GetSingleWordInOperand(0)) != + spv::StorageClass::Input) { + continue; + } + Bool located = false; + context->get_decoration_mgr()->ForEachDecoration( + variable.result_id(), static_cast(spv::Decoration::Location), + [&located](const spvtools::opt::Instruction&) { located = true; }); + if (located) return true; + } + return false; + } + bool ShaderCompiler::DemoteFloat64ToFloat32(const Vector& inputBinary, Vector& outputBinary, const bool enableSpirvValidation) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index bf92aafd..70664175 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -427,6 +427,19 @@ namespace MobileGL { // module (see its header for the two operations that make it decline), which is // what the backends report: no mobile driver can build such a module. static Bool ModuleDeclaresFloat64(const Vector& 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& spirv); }; } // namespace ShaderTranspiler } // namespace MG_Util From bc4b62026ab8e650a2e5e94eb4533e96a20b0b19 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 05:24:49 -0400 Subject: [PATCH 13/23] [Docs] (ShaderTranspiler): record the byte-exact fp64 block-layout evidence and its one misleading artifact --- .../SpirvPasses/DemoteFloat64Pass.h | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h index 3259f2cb..b095c60f 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h @@ -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 From 31367de628f912d74cf9ba9ecdbcb060fa4c6a8c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 05:27:45 -0400 Subject: [PATCH 14/23] [Test] (MG_IntegrationTest): cover a storage block's default binding where another resource competes for it --- .../Scenarios/SsboDeclarationFormScenario.cpp | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp index 321f7514..ba89a3e1 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/SsboDeclarationFormScenario.cpp @@ -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 From 0cef345d61e6c9441108031698a6f6ddea997b4f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:10:47 -0400 Subject: [PATCH 15/23] [Fix, Test] (GLState): give an atomic counter array its packed stride of four --- .../GLState/ProgramState/ProgramObject.h | 7 ++ MobileGL/MG_Test/Program/ProgramTest.cpp | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index ce58c551..13c50f78 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -373,6 +373,13 @@ namespace MobileGL::MG_State::GLState { const auto& uniform = UniformAt(TProgramUniformIndex(index)); if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1; if (!uniform.type.isArray) return 0; + // An atomic counter reaches the std140 branch below only because the transpiler + // lowered it onto a synthesized block; the buffer it actually addresses is an + // ATOMIC COUNTER buffer, whose elements are tightly packed uints (GL 4.6 core 7.6: + // "each counter is a single 4-byte value"). Its array stride is therefore 4, not the + // vec4 round-up std140 would apply + // (KHR-GL43.shader_atomic_counters.basic-program-query wants 4 for ac_counter67[0]). + if (IsActiveUniformAtomicCounter(index)) return 4; if (uniform.type.isMatrix) { const bool rowMajor = GetActiveUniformIsRowMajor(index) != 0; const int vectors = rowMajor ? uniform.type.matrixRows : uniform.type.matrixCols; diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index 0383837a..924b8f83 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -3401,3 +3401,81 @@ 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(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); +} From 8587b83be3960796d9c7da5b0535080d9ab5ecc0 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:11:04 -0400 Subject: [PATCH 16/23] [Fix, Test] (ShaderTranspiler, DirectGLES): make every emitted image-array subscript a compile-time constant --- CMakeLists.txt | 2 +- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 42 +- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 152 +++--- MobileGL/MG_Backend/DirectGLES/Utils.h | 53 +- .../Scenarios/ImageLoadStoreSsoScenario.cpp | 20 +- .../Backend/DirectGLES/EsslShaderPassTest.cpp | 181 ++++--- .../MG_Test/ShaderTranspiler/CMakeLists.txt | 2 +- .../LegalizeResourceArrayIndexTest.cpp | 505 ++++++++++++++++++ .../LegalizeStorageBlockArrayIndexTest.cpp | 251 --------- .../ShaderTranspiler/ShaderCompiler.cpp | 32 +- .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 28 +- ...cpp => LegalizeResourceArrayIndexPass.cpp} | 422 +++++++++++++-- .../LegalizeResourceArrayIndexPass.h | 157 ++++++ .../LegalizeStorageBlockArrayIndexPass.h | 120 ----- .../ShaderTranspiler/TranslationCache.h | 2 +- 15 files changed, 1319 insertions(+), 650 deletions(-) create mode 100644 MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp delete mode 100644 MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp rename MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/{LegalizeStorageBlockArrayIndexPass.cpp => LegalizeResourceArrayIndexPass.cpp} (57%) create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.h delete mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c357c43..e6e6259c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -303,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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index cf8a4134..c7c2bfd8 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5675,7 +5675,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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 LegalizeStorageBlockArrayIndexingForEssl passes: + // 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. @@ -5765,24 +5765,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 blockArrayIndexSpirv; - if (MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl( + if (MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeResourceArrayIndexingForEssl( *effectiveSpirv, blockArrayIndexSpirv, enableSpirvValidation) && !blockArrayIndexSpirv.empty()) { effectiveSpirv = &blockArrayIndexSpirv; @@ -6400,16 +6407,15 @@ namespace MobileGL::MG_Backend::DirectGLES { 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. Widen the array to - // cover the span and route its subscripts through a constant offset table. 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 both halves inherit the widened extent and the rebased binding. + // 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 declinedImageArrays; - source = RemapImageArrayElementUnits( - source, nonConsecutiveImageArrays, - AdvertisedStageImageUniformLimit(shader->GetShaderStage()), &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 diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index aaffdcb2..b9af004c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -1045,8 +1045,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } namespace { - // The digits of an array extent, or -1 for "not a plain literal size". - Int ParseArrayExtent(const String& text) { + // The digits of an array extent or of an element subscript, or -1 for "not a plain + // decimal literal". + Int ParseNonNegativeIntLiteral(const String& text) { if (text.empty()) return -1; Int value = 0; for (const char c : text) { @@ -1059,7 +1060,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } // namespace String RemapImageArrayElementUnits(const String& glslCode, const Vector& plans, - const Int stageImageUniformBudget, Vector* outDeclined) { + Vector* outDeclined) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -1082,10 +1083,9 @@ namespace MobileGL::MG_Backend::DirectGLES { SizeT declStart = 0; SizeT declLength = 0; }; - // Every image declaration in the stage, because the budget test below is about the - // stage's total and not about this one array. + // Every image declaration in the stage; the plans are program-wide and name arrays + // this stage may not declare at all. Vector decls; - Int stageImageUniforms = 0; for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) { const std::smatch& match = *it; StageImageDecl decl; @@ -1093,10 +1093,9 @@ namespace MobileGL::MG_Backend::DirectGLES { decl.qualifiers = NormalizeDeclarationSpacing(match[2].str()); decl.type = match[3].str(); decl.name = match[4].str(); - decl.elementCount = match[5].matched ? ParseArrayExtent(match[5].str()) : 1; + decl.elementCount = match[5].matched ? ParseNonNegativeIntLiteral(match[5].str()) : 1; decl.declStart = static_cast(match.position(0)); decl.declLength = match[0].str().size(); - stageImageUniforms += decl.elementCount > 0 ? decl.elementCount : 1; decls.push_back(Move(decl)); } @@ -1130,20 +1129,17 @@ namespace MobileGL::MG_Backend::DirectGLES { continue; } - Int minUnit = plan.units[0]; - Int maxUnit = plan.units[0]; Bool consecutive = true; + Bool everyElementHasAUnit = true; for (SizeT element = 0; element < plan.units.size(); ++element) { const Int unit = plan.units[element]; if (unit < 0) { - minUnit = -1; + everyElementHasAUnit = false; break; } if (unit != plan.units[0] + static_cast(element)) consecutive = false; - minUnit = std::min(minUnit, unit); - maxUnit = std::max(maxUnit, unit); } - if (minUnit < 0) { + if (!everyElementHasAUnit) { decline("an element has no image unit"); continue; } @@ -1151,26 +1147,21 @@ namespace MobileGL::MG_Backend::DirectGLES { // repeating the test here keeps the pass correct on its own terms. if (consecutive) continue; - const Int spanSize = maxUnit - minUnit + 1; - // The array has to COVER every unit from the lowest to the highest, because ESSL - // hands an array's elements consecutive units and nothing else can move them. - // The elements in between are declared and never accessed; what they cost is - // image-uniform budget, so that is what is checked. - if (stageImageUniformBudget > 0 && - stageImageUniforms - decl->elementCount + spanSize > stageImageUniformBudget) { - decline("the units are spread too far apart to cover within this stage's " - "GL_MAX_*_IMAGE_UNIFORMS"); - continue; - } - - // Every use has to be `name[...]`, or the element index has nowhere to be - // rewritten and the array cannot be widened underneath it. - struct Subscript { - SizeT open; - SizeT close; + // Every use has to be `name[]`. 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 subscripts; - Bool everyUseIsSubscripted = true; + Vector 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; @@ -1181,7 +1172,8 @@ namespace MobileGL::MG_Backend::DirectGLES { } const SizeT open = glslCode.find_first_not_of(" \t\r\n", after); if (open == String::npos || glslCode[open] != '[') { - everyUseIsSubscripted = false; + refusal = "it is reached by something other than a subscript, so there is no " + "element index to rewrite"; break; } Int depth = 0; @@ -1194,63 +1186,71 @@ namespace MobileGL::MG_Backend::DirectGLES { } } if (scan >= glslCode.size() || open + 1 >= scan) { - everyUseIsSubscripted = false; + refusal = "it is reached by something other than a subscript, so there is no " + "element index to rewrite"; break; } - subscripts.push_back({open, scan}); + 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(element)}); } - if (!everyUseIsSubscripted) { - decline("it is reached by something other than a subscript, so there is no element " - "index to rewrite"); + if (refusal != nullptr) { + decline(refusal); continue; } - const String mapName = - MakeImageAliasName(IMAGE_UNIT_MAP_PREFIX, plan.name, glslCode, takenNames); - takenNames.push_back(mapName); - - // The widened declaration, rebased on the lowest unit... - String layout = decl->layout; - const String bindingText = "binding = " + std::to_string(minUnit); - if (std::regex_search(layout, bindingValueRegex)) { - layout = std::regex_replace(layout, bindingValueRegex, bindingText); - } else { - layout = bindingText + (layout.empty() ? String() : ", " + layout); - } - String replacement = "layout(" + layout + ") uniform "; - if (!decl->qualifiers.empty()) { - replacement += decl->qualifiers; - replacement += ' '; - } - replacement += decl->type + " " + plan.name + "[" + std::to_string(spanSize) + "];"; - // ...and the table that turns the application's element index into the offset of - // the unit that element was actually assigned. A const array is a constant - // expression when it is indexed by one, so a shader whose subscripts are literals - // keeps constant subscripts; and when the subscript is a loop counter the lookup - // stays dynamically uniform, which is what GLSL ES 3.20 requires of an image - // array index. + // 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(); - replacement += "\nconst highp int " + mapName + "[" + std::to_string(elementCount) + "] = int[" + - std::to_string(elementCount) + "]("; + Vector elementNames; + String replacement; for (SizeT element = 0; element < elementCount; ++element) { - if (element != 0) replacement += ", "; - replacement += std::to_string(plan.units[element] - minUnit); + 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 + ";"; } - replacement += ");"; edits.push_back({decl->declStart, decl->declLength, Move(replacement)}); - // `name[EXPR]` -> `name[[EXPR]]`, by insertion, so EXPR itself is untouched - // however it is spelled. - for (const Subscript& subscript : subscripts) { - edits.push_back({subscript.open + 1, 0, mapName + "["}); - edits.push_back({subscript.close, 0, "]"}); + // `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. Two inserts never share an - // offset: a subscript's opening and closing brackets are distinct positions and a - // declaration is replaced whole. + // 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; diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 6f3a2d18..ea9f8543 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -235,9 +235,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // stops being safe to edit by hand. String BakeImageFormatQualifiers(String glslCode, const UnorderedMap& esslFormatByUniformName); String RemoveLayoutBinding(const String& glslCode); - // Prefix of the const element->unit-offset table RemapImageArrayElementUnits declares - // next to a widened image array; the suffix is the array's own name. - constexpr const char* IMAGE_UNIT_MAP_PREFIX = "mg_imageUnitMap_"; + // 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 { @@ -253,33 +253,40 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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. + // array has no API side to fix, because ES makes glUniform1i on an image uniform an + // INVALID_OPERATION. // - // Repaired by WIDENING the array to cover every unit from the lowest it needs to the - // highest, rebasing its binding on the lowest, and routing every subscript through a - // `const highp int` table of per-element offsets. The elements in between are declared - // and never accessed. A const array indexed by a constant expression IS one, so literal - // subscripts stay literal; and a table lookup on a dynamically uniform index is itself - // dynamically uniform, which is what GLSL ES 3.20 asks of an image array index - so this - // works whether SPIRV-Cross unrolled the application's loop over the array or not. That - // is the reason for the table rather than one scalar declaration per element: scalars - // cannot be selected by a non-constant index at all. + // 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 span will not fit in `stageImageUniformBudget` - // (GL_MAX__IMAGE_UNIFORMS; <= 0 means "cannot say", and then it is not enforced), - // when the emitted extent disagrees with the reflection, or when the array is reached by - // anything other than a subscript. Silence was the whole defect here, so a decline must - // be audible. + // 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 both halves of a split inherit the widened extent and - // the rebased binding) 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. + // 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& plans, - Int stageImageUniformBudget, Vector* outDeclined = nullptr); + Vector* 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 diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp index fcdb3cf0..7828a875 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageLoadStoreSsoScenario.cpp @@ -129,21 +129,20 @@ void main() // assignment that is not consecutive (the conformance case uses 0, 2, 4, 6) has no // spelling in a single declaration. // - // RemapImageArrayElementUnits repairs it by WIDENING the array to cover every unit - // from the lowest it needs to the highest and routing each subscript through a const - // offset table. What that costs is image-uniform budget: units 0..6 need SEVEN - // fragment image uniforms where the application declared four. So the gate is the - // budget, not the backend - a driver that cannot afford the widening declines it - // (and says so at ERROR level) rather than addressing the wrong units silently. - // DirectVulkan has no such constraint and needs no widening at all. + // 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) { } - // The widest span either of the two fragment programs below needs: 0..6 and 1..7. - return maxFragmentImageUniforms >= 7; + // 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 @@ -174,8 +173,7 @@ void main() if (!Ready()) return; if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units"; if (!PerElementImageUnitsAreHonoured()) { - GTEST_SKIP() << "fewer than 7 fragment image uniforms: the widening that covers a " - "non-consecutive image array does not fit"; + GTEST_SKIP() << "fewer than 4 fragment image uniforms: the array under test does not fit"; } HeadlessGL& gl = Gl(); diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index 7e4acfb1..d01298d0 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -21,7 +21,7 @@ using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BuildPassthroughTessControlE using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ExtractPerVertexBlockMembers; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_STAGE_ALIAS_PREFIX; -using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_UNIT_MAP_PREFIX; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_ARRAY_ELEMENT_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageStageAliasPrefix; @@ -52,7 +52,10 @@ namespace { // The writeonly half is minted from the ALREADY stage-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(StageAlias(name)); } - String UnitMap(const String& name) { return String(IMAGE_UNIT_MAP_PREFIX) + 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 @@ -517,17 +520,18 @@ TEST(SplitReadWriteImageUniformsTest, EveryStageTagIsDistinct) { // 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, written - // through a loop counter (which is how SPIRV-Cross emits the conformance case's - // `for (int i = 0; i < g_image.length(); ++i)` when it does not unroll it). + // 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() { - for (int i = 0; i < 4; i++) - { - imageStore(g_image[i], ivec2(gl_FragCoord.xy), vec4(1.0)); - } + 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)); } )"; @@ -539,26 +543,34 @@ void main() } } // namespace -// The defect, end to end. Elements 0..3 need units 0, 2, 4, 6, so the array is widened to cover -// units 0..6 and every subscript is routed through the offset table. Before this, the single -// stamped binding sent the four elements to units 0, 1, 2, 3. -TEST(RemapImageArrayElementUnitsTest, NonConsecutiveUnitsWidenTheArrayAndRouteEverySubscript) { +// 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 declined; const String out = - RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, 8, &declined); + RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, &declined); EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]); - // Seven elements from binding 0, i.e. units 0..6 - the span the four assigned units need. - EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[7];")) << out; - EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[4] = int[4](0, 2, 4, 6);")) << out; - EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("g_image") + "[i]], ivec2(gl_FragCoord.xy)")) << out; - // The original four-element extent is gone; nothing may still address units 0,1,2,3. + 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, so the binding rebases onto -// the LOWEST unit rather than staying on element [0]'s. -TEST(RemapImageArrayElementUnitsTest, TheBindingRebasesOntoTheLowestUnitInTheSpan) { +// 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() @@ -567,14 +579,17 @@ void main() imageStore(g_image[3], ivec2(0), vec4(2.0)); } )"; - const String out = RemapImageArrayElementUnits(source, {Plan("g_image", {3, 1, 7, 5})}, 8); - EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = 1) uniform writeonly highp image2D g_image[7];")) << out; - // Offsets from the new base, in the application's element order - the order is what carries - // the assignment, so it must NOT be sorted. - EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[4] = int[4](2, 0, 6, 4);")) << out; - // A literal subscript stays a constant expression: a const array indexed by one is one. - EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("g_image") + "[0]], ivec2(0)")) << out; - EXPECT_TRUE(Contains(out, "imageStore(g_image[" + UnitMap("g_image") + "[3]], ivec2(0)")) << out; + 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 @@ -588,33 +603,49 @@ void main() imageStore(g_image[1], ivec2(0), vec4(1.0)); } )"; - EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {2, 3, 4})}, 8), source); + 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})}, 8), source); + EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("other_image", {0, 4})}), source); } -// Widening costs image-uniform budget, and a stage that cannot afford it must be told so rather -// than silently addressing the wrong units - the exact silence this whole pass exists to end. -TEST(RemapImageArrayElementUnitsTest, ASpanThatExceedsTheStageBudgetIsDeclinedAndNamed) { +// 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 declined; - const String out = - RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, 4, &declined); - EXPECT_EQ(out, String(kSsoImageArrayFS)) << out; + 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 budget of "cannot say" (the ES side reports no limit for this stage) must not be read as - // a budget of zero - that would decline every array on a driver that simply does not answer. - Vector unknownBudget; - const String repaired = - RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, -1, &unknownBudget); - EXPECT_TRUE(unknownBudget.empty()); - EXPECT_TRUE(Contains(repaired, "image2D g_image[7];")) << repaired; + // 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 outOfRangeDeclined; + EXPECT_EQ(RemapImageArrayElementUnits(outOfRange, {Plan("g_image", {0, 5})}, &outOfRangeDeclined), + outOfRange); + ASSERT_EQ(outOfRangeDeclined.size(), 1u); } -// A use the pass cannot see a subscript on has no element index to rewrite, so widening the -// array underneath it would change which unit it reaches. Decline, loudly, and change nothing. +// 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]; @@ -626,7 +657,7 @@ void main() } )"; Vector declined; - EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 5})}, 8, &declined), source); + 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]; } @@ -642,14 +673,15 @@ void main() } )"; Vector declined; - EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, 16, &declined), source); + 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 widened declaration and keep its binding: an +// 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 a read+write array is split into two declarations that must BOTH be the widened one. -TEST(RemapImageArrayElementUnitsTest, TheWidenedArraySurvivesTheLaterImagePasses) { +// 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() @@ -657,19 +689,44 @@ void main() imageStore(g_image[1], ivec2(0), imageLoad(g_image[0], ivec2(0))); } )"; - String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 6})}, 8); + String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 6})}); out = SplitReadWriteImageUniforms(out, kStage); out = RemoveLayoutBinding(out); - // Both halves, both widened to the three units 4..6, and both still bound. - EXPECT_EQ(CountOf(out, "binding = 4"), 2u) << out; - EXPECT_TRUE(Contains(out, "image2D " + StageAlias("g_image") + "[3];")) << out; - EXPECT_TRUE(Contains(out, "image2D " + SplitWriteAlias("g_image") + "[3];")) << out; - // The offset table is untouched by the rename - it is not an image uniform - and both halves - // still route their subscripts through it. - EXPECT_TRUE(Contains(out, "const highp int " + UnitMap("g_image") + "[2] = int[2](0, 2);")) << out; - EXPECT_TRUE(Contains(out, SplitWriteAlias("g_image") + "[" + UnitMap("g_image") + "[1]]")) << out; - EXPECT_TRUE(Contains(out, StageAlias("g_image") + "[" + UnitMap("g_image") + "[0]]")) << 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 " + StageAlias(Elem("g_image", 0)) + ";")) << out; + EXPECT_TRUE(Contains(out, "writeonly highp image2D " + StageAlias(Elem("g_image", 1)) + ";")) << out; + EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias(Elem("g_image", 1)) + ", ivec2(0), imageLoad(" + + StageAlias(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, kStage); + 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 " + StageAlias(Elem("g_image", 1)) + ";")) << out; + EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WriteAlias(StageAlias(Elem("g_image", 1))) + ";")) + << out; + EXPECT_EQ(CountOf(out, "binding = 4"), 1u) << out; } // --------------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index ba0df960..8e92d409 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -12,7 +12,7 @@ add_executable( UniquifyIoBlockNamesTest.cpp LowerViewportIndexTest.cpp ClampMultisampleFetchTest.cpp - LegalizeStorageBlockArrayIndexTest.cpp + LegalizeResourceArrayIndexTest.cpp FlattenAtomicCounterBlockTest.cpp WidenImageFormatsTest.cpp ) diff --git a/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp new file mode 100644 index 00000000..19cfb6c6 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp @@ -0,0 +1,505 @@ +// 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 + +#define SPV_ENABLE_UTILITY_CODE +#include "glslang/SPIRV/spirv.hpp11" +#undef SPV_ENABLE_UTILITY_CODE + +#include "Includes.h" +#include +#include +#include + +#include + +#include +#include + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; + +namespace { + constexpr SizeT kSpirvHeaderWordCount = 5u; + + template + void ForEachInstruction(const Vector& 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(spirv[offset] & 0xffffu), &spirv[offset], wordCount); + offset += wordCount; + } + } + + Vector 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& 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& 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& spirv) { + std::set blockStructs; // OpTypeStruct ids decorated Block / BufferBlock + std::set constants; // OpConstant / OpConstantNull result ids + std::set blockArrayTypes; // OpTypeArray ids whose element is such a struct + std::set blockArrayPointers;// OpTypePointer ids pointing at one of those arrays + std::set 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(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 + if (wordCount >= 4u && blockStructs.count(words[2]) != 0u) { + blockArrayTypes.insert(words[1]); + } + break; + case spv::Op::OpTypePointer: + // OpTypePointer + if (wordCount >= 4u && blockArrayTypes.count(words[3]) != 0u) { + blockArrayPointers.insert(words[1]); + } + break; + case spv::Op::OpVariable: + // OpVariable + 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 ... + 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& spirv) { + std::set storageImages; // OpTypeImage ids with Sampled == 2 + std::set constants; // OpConstant / OpConstantNull result ids + std::set imageArrayTypes; // OpTypeArray ids whose element is such an image + std::set imageArrayPointers; // OpTypePointer ids pointing at one of those arrays + std::set 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 + 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& 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)); + } +} +)"; + + // A uniform-sourced image index: nothing can fold it, so the switch/select lowering is what + // has to carry it. Both directions in one shader, as the block-array fixture does. + constexpr const char* kUniformIndexedImageArray = R"(#version 450 core +layout(local_size_x = 1) in; +layout(rgba32f, binding = 0) uniform image2D g_image[4]; +layout(std430, binding = 8) buffer Out { vec4 value; } g_out; +uniform int g_index; +void main() { + imageStore(g_image[g_index], ivec2(0), vec4(7.0)); + g_out.value = imageLoad(g_image[g_index], ivec2(1)); +} +)"; + + // An image array indexed only with literals is already legal ES. + constexpr const char* kConstantIndexedImageArray = R"(#version 450 core +layout(local_size_x = 1) in; +layout(rgba32f, binding = 0) uniform writeonly image2D g_image[4]; +void main() { + imageStore(g_image[1], ivec2(0), vec4(1.0)); + imageStore(g_image[3], ivec2(0), vec4(2.0)); +} +)"; + + // The positive control for the scope decision: ESSL 3.20 4.1.7 allows a SAMPLER array a + // dynamically-uniform index, and the same raw GLES probe confirms it - both a loop-variable + // subscript and a const-table lookup compile and link. Nothing here may be rewritten. + constexpr const char* kUniformIndexedSamplerArray = R"(#version 450 core +layout(local_size_x = 1) in; +uniform sampler2D g_tex[4]; +layout(std430, binding = 8) buffer Out { vec4 value; } g_out; +uniform int g_index; +void main() { + g_out.value = texture(g_tex[g_index], vec2(0.5)); +} +)"; + + // An imageAtomic* reaches the array through OpImageTexelPointer, and running one per element + // would perform every other element's atomic as well. The pass has to decline rather than + // lower this. + constexpr const char* kUniformIndexedImageAtomic = R"(#version 450 core +layout(local_size_x = 1) in; +layout(r32ui, binding = 0) uniform uimage2D g_image[4]; +layout(std430, binding = 8) buffer Out { uint value; } g_out; +uniform int g_index; +void main() { + g_out.value = imageAtomicAdd(g_image[g_index], ivec2(0), 1u); +} +)"; +} // namespace + +TEST(LegalizeResourceArrayIndexPass, FoldsALoopIndexedBlockArray) { + const Vector input = CompileCompute(kLoopIndexedBlockArray); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicBlockArrayIndex(input)); + + Vector 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 input = CompileCompute(kUniformIndexedBlockArray); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicBlockArrayIndex(input)); + EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u); + + Vector 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 input = CompileCompute(kArrayMemberInsideOneBlock); + ASSERT_FALSE(input.empty()); + EXPECT_FALSE(HasDynamicBlockArrayIndex(input)); + + Vector output; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true)); + EXPECT_EQ(output, input); +} + +TEST(LegalizeResourceArrayIndexPass, LeavesAConstantIndexedBlockArrayByteIdentical) { + const Vector input = CompileCompute(kConstantIndexedBlockArray); + ASSERT_FALSE(input.empty()); + EXPECT_FALSE(HasDynamicBlockArrayIndex(input)); + + Vector output; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true)); + EXPECT_EQ(output, input); +} + +TEST(LegalizeResourceArrayIndexPass, IsIdempotent) { + const Vector input = CompileCompute(kUniformIndexedBlockArray); + ASSERT_FALSE(input.empty()); + + Vector once; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, once, true)); + ASSERT_FALSE(once.empty()); + + Vector 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 input = CompileCompute(kLoopIndexedImageArray); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicImageArrayIndex(input)); + + Vector 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; + } +} + +TEST(LegalizeResourceArrayIndexPass, LowersAUniformIndexedImageWriteToASwitchAndAReadToSelects) { + const Vector input = CompileCompute(kUniformIndexedImageArray); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicImageArrayIndex(input)); + EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u); + + Vector 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 input = CompileCompute(kConstantIndexedImageArray); + ASSERT_FALSE(input.empty()); + EXPECT_FALSE(HasDynamicImageArrayIndex(input)); + + Vector 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 input = CompileCompute(kUniformIndexedSamplerArray); + ASSERT_FALSE(input.empty()); + EXPECT_FALSE(HasDynamicImageArrayIndex(input)); + + Vector output; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true)); + EXPECT_EQ(output, input); +} + +// An imageAtomic* is the shape the lowering must refuse: its per-element rebuild would run every +// other element's read-modify-write. Declining leaves the illegal subscript in place - which is +// what the latched warning in LegalizeResourceArrayIndexingForEssl is for - but a half-transform +// would corrupt four images instead of losing one stage. +TEST(LegalizeResourceArrayIndexPass, DeclinesAUniformIndexedImageAtomic) { + const Vector input = CompileCompute(kUniformIndexedImageAtomic); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicImageArrayIndex(input)); + + Vector 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 input = CompileCompute(kUniformIndexedImageArray); + ASSERT_FALSE(input.empty()); + + Vector once; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, once, true)); + ASSERT_FALSE(once.empty()); + + Vector twice; + ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(once, twice, true)); + EXPECT_EQ(twice, once); +} diff --git a/MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp deleted file mode 100644 index f4c7bfbd..00000000 --- a/MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp +++ /dev/null @@ -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 - -#define SPV_ENABLE_UTILITY_CODE -#include "glslang/SPIRV/spirv.hpp11" -#undef SPV_ENABLE_UTILITY_CODE - -#include "Includes.h" -#include -#include - -#include - -#include -#include - -using namespace MobileGL; -using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; - -namespace { - constexpr SizeT kSpirvHeaderWordCount = 5u; - - template - void ForEachInstruction(const Vector& 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(spirv[offset] & 0xffffu), &spirv[offset], wordCount); - offset += wordCount; - } - } - - Vector 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& 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& 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& spirv) { - std::set blockStructs; // OpTypeStruct ids decorated Block / BufferBlock - std::set constants; // OpConstant / OpConstantNull result ids - std::set blockArrayTypes; // OpTypeArray ids whose element is such a struct - std::set blockArrayPointers;// OpTypePointer ids pointing at one of those arrays - std::set 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(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 - if (wordCount >= 4u && blockStructs.count(words[2]) != 0u) { - blockArrayTypes.insert(words[1]); - } - break; - case spv::Op::OpTypePointer: - // OpTypePointer - if (wordCount >= 4u && blockArrayTypes.count(words[3]) != 0u) { - blockArrayPointers.insert(words[1]); - } - break; - case spv::Op::OpVariable: - // OpVariable - 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 ... - 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 input = CompileCompute(kLoopIndexedBlockArray); - ASSERT_FALSE(input.empty()); - EXPECT_TRUE(HasDynamicBlockArrayIndex(input)); - - Vector 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 input = CompileCompute(kUniformIndexedBlockArray); - ASSERT_FALSE(input.empty()); - EXPECT_TRUE(HasDynamicBlockArrayIndex(input)); - EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u); - - Vector 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 input = CompileCompute(kArrayMemberInsideOneBlock); - ASSERT_FALSE(input.empty()); - EXPECT_FALSE(HasDynamicBlockArrayIndex(input)); - - Vector output; - ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true)); - EXPECT_EQ(output, input); -} - -TEST(LegalizeStorageBlockArrayIndexPass, LeavesAConstantIndexedBlockArrayByteIdentical) { - const Vector input = CompileCompute(kConstantIndexedBlockArray); - ASSERT_FALSE(input.empty()); - EXPECT_FALSE(HasDynamicBlockArrayIndex(input)); - - Vector output; - ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true)); - EXPECT_EQ(output, input); -} - -TEST(LegalizeStorageBlockArrayIndexPass, IsIdempotent) { - const Vector input = CompileCompute(kUniformIndexedBlockArray); - ASSERT_FALSE(input.empty()); - - Vector once; - ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, once, true)); - ASSERT_FALSE(once.empty()); - - Vector twice; - ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(once, twice, true)); - EXPECT_EQ(twice, once); -} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index df9301ee..25555ee4 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -43,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" @@ -1015,16 +1015,16 @@ namespace MobileGL { return true; } - bool ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl( + bool ShaderCompiler::LegalizeResourceArrayIndexingForEssl( const Vector& inputBinary, Vector& 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; @@ -1040,7 +1040,7 @@ namespace MobileGL { // induction variable as an OpPhi, and glslang emits it as loads and stores of // a Function variable. folder.RegisterPass(CreateLocalMultiStoreElimPass()); - folder.RegisterPass(LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass()); + folder.RegisterPass(LegalizeResourceArrayIndexPass::CreateMarkLoopsForUnrollPass()); folder.RegisterPass(CreateLoopUnrollPass(true)); // Fold the unrolled induction values into the access chains, then clear out // what constant conditions leave behind. @@ -1050,14 +1050,14 @@ namespace MobileGL { folder.RegisterPass(CreateBlockMergePass()); Vector 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; @@ -1066,25 +1066,25 @@ namespace MobileGL { // Genuinely dynamic (uniform-derived, non-constant trip count, ...): lower it. Optimizer lowerer(SPV_ENV_VULKAN_1_1); lowerer.RegisterPass( - LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass()); + LegalizeResourceArrayIndexPass::CreateLowerToConstantSwitchPass()); // The chains the lowering replaced are dead now; remove_outputs must stay // false here for the same reason it does in SanitizeAndOptimizeBinary. lowerer.RegisterPass(CreateAggressiveDCEPass(false)); - if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.lower", lowerer, folded, + if (!RunOptimizerChecked("LegalizeResourceArrayIndexingForEssl.lower", lowerer, folded, outputBinary, true, enableSpirvValidation) || outputBinary.empty()) { outputBinary = folded; return true; } - if (LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing( + if (LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing( outputBinary)) { // MGLOG_W, latched, for the same reason the fragment-output one is: this // runs per shader compile and shader packs compile lazily mid-session. - MGLOG_W_ONCE("[spirv] LegalizeStorageBlockArrayIndexingForEssl: an array of storage " - "blocks is still indexed dynamically; a strict ES driver will reject " - "this shader"); + MGLOG_W_ONCE("[spirv] LegalizeResourceArrayIndexingForEssl: an array of storage " + "blocks or of images is still indexed dynamically; a strict ES " + "driver will reject this shader"); } return true; } diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 70664175..41840591 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -161,18 +161,22 @@ namespace MobileGL { static bool LegalizeFragmentOutputIndexingForEssl(const Vector& inputBinary, Vector& 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& 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& inputBinary, Vector& outputBinary, bool enableSpirvValidation = false); // Collapses each synthesized gl_AtomicCounterBlock_ into one uint array at diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp similarity index 57% rename from MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp rename to MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp index afa25909..4d729fd6 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp @@ -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,10 +39,10 @@ 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. @@ -50,14 +50,22 @@ namespace MobileGL { // 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. + // 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. 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 +81,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 CollectStorageBlockArrays(IRContext* context) { - std::unordered_map blockArrays; + std::unordered_map CollectResourceArrays(IRContext* context) { + std::unordered_map resourceArrays; auto* defUseMgr = context->get_def_use_mgr(); auto* constantMgr = context->get_constant_mgr(); @@ -93,7 +109,8 @@ namespace MobileGL { const auto storageClass = static_cast(inst.GetSingleWordInOperand(0)); if (storageClass != spv::StorageClass::StorageBuffer && - storageClass != spv::StorageClass::Uniform) { + storageClass != spv::StorageClass::Uniform && + storageClass != spv::StorageClass::UniformConstant) { continue; } @@ -106,17 +123,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 + // + 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 +158,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 +173,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 CollectDynamicIndexUses(IRContext* context) { std::vector uses; - const std::unordered_map blockArrays = - CollectStorageBlockArrays(context); - if (blockArrays.empty()) { + const std::unordered_map resourceArrays = + CollectResourceArrays(context); + if (resourceArrays.empty()) { return uses; } @@ -164,14 +197,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}); } } } @@ -274,7 +308,7 @@ namespace MobileGL { } } // namespace - bool LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing( + bool LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing( const std::vector& binary) { if (binary.empty()) { return false; @@ -289,11 +323,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 uses = CollectDynamicIndexUses(irContext); if (uses.empty()) { @@ -337,11 +371,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 +391,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 +418,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 stores; std::vector loads; @@ -458,8 +497,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 +582,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 +633,283 @@ namespace MobileGL { return LoweringOutcome::Changed; } - spvtools::Optimizer::PassToken - LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass() { - return spvtools::Optimizer::PassToken( - MakeUnique(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 loads; + bool unsupportedUse = false; + irContext->get_def_use_mgr()->ForEachUser(accessChain, [&](Instruction* user) { + switch (user->opcode()) { + case spv::Op::OpName: + case spv::Op::OpDecorate: + case spv::Op::OpDecorateId: + return; + case spv::Op::OpLoad: + // Memory operands would be dropped by the per-element rebuild, so a + // load carrying any is refused instead. + if (user->NumInOperands() == 1) { + loads.push_back(user); + } else { + unsupportedUse = true; + } + return; + default: + // OpImageTexelPointer above all: that is how an imageAtomic* reaches + // the array, and running one per element would perform every OTHER + // element's atomic as well - a read can be thrown away, a + // read-modify-write cannot. + unsupportedUse = true; + return; + } + }); + + if (unsupportedUse) { + MGLOG_D("[spirv] image array index: chain %%%u has a use this pass cannot rewrite", + accessChain->result_id()); + return LoweringOutcome::Declined; + } + + if (loads.empty()) { + // No uses left: the chain itself is what detection is still seeing. + irContext->KillInst(accessChain); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return LoweringOutcome::Changed; + } + + Instruction* load = loads.front(); + Instruction* consumer = nullptr; + bool unsupportedConsumer = false; + irContext->get_def_use_mgr()->ForEachUser(load, [&](Instruction* user) { + switch (user->opcode()) { + case spv::Op::OpName: + case spv::Op::OpDecorate: + case spv::Op::OpDecorateId: + return; + case spv::Op::OpImageWrite: + case spv::Op::OpImageRead: + if (consumer == nullptr) consumer = user; + return; + default: + // A sampled-image construction, a query, a copy, an argument to a + // function: shapes whose per-element rebuild this pass cannot spell + // exactly. + unsupportedConsumer = true; + return; + } + }); + + if (unsupportedConsumer) { + MGLOG_D("[spirv] image array index: the image loaded from chain %%%u is consumed by " + "an operation this pass cannot rewrite", + accessChain->result_id()); + return LoweringOutcome::Declined; + } + if (consumer == nullptr) { + irContext->KillInst(load); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return LoweringOutcome::Changed; + } + + return consumer->opcode() == spv::Op::OpImageWrite + ? LowerImageWrite(accessChain, arrayLength, load, consumer) + : LowerImageRead(accessChain, arrayLength, load, consumer); + } + + // Drops |load| and |accessChain| once the rewrite above has taken their last user, + // in that order - the load is what uses the chain. Anything still using either is + // another consumer a later round will come back for. + void LegalizeResourceArrayIndexPass::KillImageChainIfDead(Instruction* accessChain, + Instruction* load) { + auto* irContext = context(); + if (irContext->get_def_use_mgr()->NumUsers(load) == 0) { + irContext->KillInst(load); + } + if (irContext->get_def_use_mgr()->NumUsers(accessChain) == 0) { + irContext->KillInst(accessChain); + } + } + + // switch (idx) { case 0: imageStore(arr[0], ...); break; case 1: ... } + // + // The same block split as LowerStore, for the same reason: whatever followed the + // write still runs exactly once on every path, and an index outside [0, length) + // reaches the default target - the merge block - so nothing is written, which is + // what indexing an image array out of range already meant. + LegalizeResourceArrayIndexPass::LoweringOutcome + LegalizeResourceArrayIndexPass::LowerImageWrite(Instruction* accessChain, uint32_t arrayLength, + Instruction* load, Instruction* imageWrite) { + auto* irContext = context(); + BasicBlock* block = irContext->get_instr_block(imageWrite); + if (block == nullptr) { + return LoweringOutcome::Declined; + } + // Splitting a loop header keeps the label - and so the back edge's target - on + // the first half while the OpLoopMerge moves to the second, which is not a loop + // any more. Refuse instead of producing that. + if (block->GetLoopMergeInst() != nullptr) { + MGLOG_D("[spirv] image array index: write sits in a loop header, declining"); + return LoweringOutcome::Declined; + } + Function* function = block->GetParent(); + if (function == nullptr) { + return LoweringOutcome::Declined; + } + + const uint32_t indexId = accessChain->GetSingleWordInOperand(1); + const uint32_t imageTypeId = load->type_id(); + // Coordinate, texel and any image operands, verbatim: only the image itself is + // per-element. + std::vector 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> targets; + targets.reserve(arrayLength); + BasicBlock* insertAfter = block; + for (uint32_t element = 0; element < arrayLength; ++element) { + const uint32_t caseLabelId = irContext->TakeNextId(); + auto caseBlock = MakeUnique(MakeUnique( + irContext, spv::Op::OpLabel, 0, caseLabelId, std::initializer_list{})); + 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 writeOperands; + writeOperands.push_back({SPV_OPERAND_TYPE_ID, {elementImage->result_id()}}); + for (const Operand& tailOperand : tailOperands) { + writeOperands.push_back(tailOperand); + } + caseBuilder.AddInstruction( + MakeUnique(irContext, spv::Op::OpImageWrite, 0, 0, writeOperands)); + caseBuilder.AddBranch(mergeLabelId); + + targets.push_back({Operand::OperandData{element}, caseLabelId}); + insertAfter = casePtr; + } + + InstructionBuilder switchBuilder( + irContext, block, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + switchBuilder.AddSwitch(indexId, mergeLabelId, targets, mergeLabelId); + + KillImageChainIfDead(accessChain, load); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + MGLOG_D("[spirv] image array index: lowered a dynamic imageStore to a %u-way switch", + arrayLength); + return LoweringOutcome::Changed; + } + + // A read needs no control flow: read every element through a constant index and pick + // with OpSelect. The selection happens on the RESULT, not on the image object - an + // opaque type may not be selected at all (pre-1.4 OpSelect takes pointers, scalars + // and vectors only, and ESSL has no ternary on an image), so what is duplicated is + // the OpImageRead. + // + // Reading the elements the shader did not ask for is safe: every one of them is an + // image this stage already declares, and GL 4.6 7.11.2 makes a load through an + // image unit whose binding is missing or incompatible return undefined DATA - never + // an error, and never a fault - which the select then discards. Contrast an + // imageAtomic*, which LowerImageChain refuses for exactly the opposite reason. + LegalizeResourceArrayIndexPass::LoweringOutcome + LegalizeResourceArrayIndexPass::LowerImageRead(Instruction* accessChain, uint32_t arrayLength, + Instruction* load, Instruction* imageRead) { + auto* irContext = context(); + uint32_t conditionTypeId = 0; + uint32_t dimension = 0; + if (!TryGetSelectConditionType(irContext, imageRead->type_id(), &conditionTypeId, + &dimension)) { + MGLOG_D("[spirv] image array index: read type is not selectable, declining"); + return LoweringOutcome::Declined; + } + const uint32_t boolTypeId = irContext->get_type_mgr()->GetBoolTypeId(); + const uint32_t indexId = accessChain->GetSingleWordInOperand(1); + const uint32_t imageTypeId = load->type_id(); + std::vector tailOperands; + for (uint32_t i = 1; i < imageRead->NumInOperands(); ++i) { + tailOperands.push_back(imageRead->GetInOperand(i)); + } + + InstructionBuilder builder( + irContext, imageRead, + IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + + uint32_t selectedId = 0; + for (uint32_t element = 0; element < arrayLength; ++element) { + const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element); + Instruction* elementChain = + CloneChainWithConstantIndex(builder, irContext, accessChain, constantId); + Instruction* elementImage = builder.AddLoad(imageTypeId, elementChain->result_id()); + + std::vector readOperands; + readOperands.push_back({SPV_OPERAND_TYPE_ID, {elementImage->result_id()}}); + for (const Operand& tailOperand : tailOperands) { + readOperands.push_back(tailOperand); + } + Instruction* elementRead = builder.AddInstruction( + MakeUnique(irContext, spv::Op::OpImageRead, imageRead->type_id(), + irContext->TakeNextId(), readOperands)); + if (element == 0) { + // Element 0 is the else-arm of the whole ladder, so an out-of-range + // index reads it - an undefined element for an undefined index. + selectedId = elementRead->result_id(); + continue; + } + + Instruction* isElement = + builder.AddBinaryOp(boolTypeId, spv::Op::OpIEqual, indexId, constantId); + uint32_t conditionId = isElement->result_id(); + if (dimension > 1) { + std::vector components(dimension, conditionId); + conditionId = builder.AddCompositeConstruct(conditionTypeId, components)->result_id(); + } + selectedId = builder + .AddSelect(imageRead->type_id(), conditionId, + elementRead->result_id(), selectedId) + ->result_id(); + } + + irContext->ReplaceAllUsesWith(imageRead->result_id(), selectedId); + irContext->KillInst(imageRead); + KillImageChainIfDead(accessChain, load); + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + MGLOG_D("[spirv] image array index: lowered a dynamic imageLoad to %u constant-indexed " + "reads", + arrayLength); + return LoweringOutcome::Changed; } spvtools::Optimizer::PassToken - LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass() { + LegalizeResourceArrayIndexPass::CreateMarkLoopsForUnrollPass() { return spvtools::Optimizer::PassToken( - MakeUnique(Mode::LowerToConstantSwitch)); + MakeUnique(Mode::MarkLoopsForUnroll)); + } + + spvtools::Optimizer::PassToken + LegalizeResourceArrayIndexPass::CreateLowerToConstantSwitchPass() { + return spvtools::Optimizer::PassToken( + MakeUnique(Mode::LowerToConstantSwitch)); } } // namespace ShaderTranspiler } // namespace MG_Util diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.h new file mode 100644 index 00000000..f77d542d --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.h @@ -0,0 +1,157 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include +#include + +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& binary); + + private: + enum class LoweringOutcome { + // The shape is not one this pass can rewrite exactly; the module keeps + // the illegal chain rather than a half-transform of it. + Declined, + Changed, + }; + + Status MarkLoopsForUnroll(); + Status LowerToConstantSwitch(); + + LoweringOutcome LowerOneChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + bool isImageArray); + LoweringOutcome LowerStore(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* store); + LoweringOutcome LowerLoad(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* load); + LoweringOutcome LowerImageChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength); + void KillImageChainIfDead(spvtools::opt::Instruction* accessChain, + spvtools::opt::Instruction* load); + LoweringOutcome LowerImageWrite(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* load, + spvtools::opt::Instruction* imageWrite); + LoweringOutcome LowerImageRead(spvtools::opt::Instruction* accessChain, uint32_t arrayLength, + spvtools::opt::Instruction* load, + spvtools::opt::Instruction* imageRead); + + Mode m_mode; + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.h deleted file mode 100644 index 390acc15..00000000 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.h +++ /dev/null @@ -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 -#include - -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& 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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index 45f89a22..43f989fa 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -566,7 +566,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // // Unconditional passes take no input but the module and so need no key material: // StripUboMemberRelaxedPrecision, LowerRectImages, Lower1DArrayImages, - // Lower1DSampledImages, LegalizeStorageBlockArrayIndexing and + // 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. // From b32c35a113375df10c5b3bc45143a0343e4a6240 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:20:54 -0400 Subject: [PATCH 17/23] [Fix, Test] (GLImpl): ask the stage, not GL_NONE, whether a geometry shader is active --- .../MG_Impl/GLImpl/Drawing/GL_Drawing.cpp | 14 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Scenarios/GeometryDrawModeScenario.cpp | 298 ++++++++++++++++++ 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.cpp diff --git a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp index fe5cc80b..e73c1dd6 100644 --- a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp +++ b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp @@ -230,8 +230,18 @@ namespace MobileGL::MG_Impl::GLImpl { // input primitive (GL 4.6 core 11.3.1); anything else is INVALID_OPERATION. GL_PATCHES // is the tessellation pipeline's input and reaches the geometry stage already // converted, so it is not constrained here. - const GLenum gsInput = currentProgram ? currentProgram->GetGeometryInputType() : GL_NONE; - if (gsInput != GL_NONE && mode != GL_PATCHES) { + // + // "Is there a geometry stage at all" has to be asked of the STAGE, never of the input + // primitive: GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry shader + // is indistinguishable from no geometry shader by its reflected input type alone. The + // sentinel test this replaces therefore skipped the whole rule for exactly the geometry + // shaders whose input is the most restrictive one - every mode but GL_POINTS was + // accepted (KHR-GL43.transform_feedback.api_errors_test draws a points-in geometry + // program with GL_LINES and requires INVALID_OPERATION). + const Bool geometryActive = + currentProgram && currentProgram->GetShaderIndexByStage(ShaderStage::Geometry) >= 0; + const GLenum gsInput = geometryActive ? currentProgram->GetGeometryInputType() : GL_NONE; + if (geometryActive && mode != GL_PATCHES) { Bool compatible = false; switch (gsInput) { case GL_POINTS: diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 04a76493..7abdeec1 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -87,6 +87,7 @@ add_executable(MobileGLIntegrationTest Scenarios/Glsl420DeclarationScenario.cpp Scenarios/IoBlockNameCollisionScenario.cpp Scenarios/TessellationDrawModeScenario.cpp + Scenarios/GeometryDrawModeScenario.cpp Scenarios/FragmentOutputArrayIndexScenario.cpp Scenarios/BufferTextureScenario.cpp Scenarios/VertexAttribBindingScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.cpp new file mode 100644 index 00000000..bf8a57c4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.cpp @@ -0,0 +1,298 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A GEOMETRY SHADER'S INPUT PRIMITIVE CONSTRAINS THE DRAW MODE, AND +// GL_NONE IS NOT A USABLE "NO GEOMETRY SHADER" SENTINEL. +// +// GL 4.6 core 11.3.1: mode must be one of the primitive types that decomposes into the +// geometry shader's declared input primitive, or the draw is GL_INVALID_OPERATION. The +// validator asked "is there a geometry stage?" by comparing the REFLECTED INPUT PRIMITIVE +// against GL_NONE - and GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry +// shader answered "no geometry stage" and every mode sailed through. The rule was therefore +// dead for exactly the geometry shaders whose input primitive rejects the most modes. +// +// KHR-GL43.transform_feedback.api_errors_test is where it showed: it draws a points-in +// geometry program with GL_LINES through glDrawTransformFeedbackInstanced and requires +// INVALID_OPERATION. The bug is not specific to that entry point - every draw shares this +// validator - so the ordinary glDrawArrays spelling is pinned here too, and the lines-in +// program is the control that proves the rule was not simply widened. +// +// Needs a real context: the validator returns before this rule when no backend object is +// active, so the GPU-free negative-API suite cannot reach it. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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> stages = { + {GL_VERTEX_SHADER, kVertexSource}, + {GL_GEOMETRY_SHADER, geometrySource}, + {GL_FRAGMENT_SHADER, kFragmentSource}}; + + std::vector 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 buffer(static_cast(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 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(GL_LINES), static_cast(GL_LINE_STRIP), + static_cast(GL_TRIANGLES), static_cast(GL_TRIANGLE_STRIP)}) { + glDrawArrays(mode, 0, 3); + EXPECT_EQ(glGetError(), static_cast(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(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(GL_NO_ERROR)) << "the capture span did not complete"; + + glDrawTransformFeedbackInstanced(GL_LINES, feedback, 1); + EXPECT_EQ(glGetError(), static_cast(GL_INVALID_OPERATION)) + << "glDrawTransformFeedbackInstanced must honour the geometry input primitive"; + DrainErrors(); + + glDrawTransformFeedbackStreamInstanced(GL_LINES, feedback, 0, 1); + EXPECT_EQ(glGetError(), static_cast(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(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(GL_LINES), static_cast(GL_LINE_STRIP), + static_cast(GL_LINE_LOOP)}) { + glDrawArrays(mode, 0, 2); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)) + << "mode " << mode << " decomposes into lines and must be accepted"; + DrainErrors(); + } + + for (const GLenum mode : {static_cast(GL_POINTS), static_cast(GL_TRIANGLES)}) { + glDrawArrays(mode, 0, 3); + EXPECT_EQ(glGetError(), static_cast(GL_INVALID_OPERATION)) + << "mode " << mode << " does not decompose into lines"; + DrainErrors(); + } + } + + } // namespace +} // namespace MGITest From 3cc6b88767eab2f84b7f39353c466fdc406201e5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:36:47 -0400 Subject: [PATCH 18/23] [Fix, Test] (GLState): keep named-block members out of the GL uniform location pool --- .../GLState/ProgramState/ProgramLinkTask.cpp | 38 ++++--- MobileGL/MG_Test/Program/ProgramTest.cpp | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 69dbfc2a..3860b355 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -1023,6 +1023,12 @@ namespace MobileGL::MG_State::GLState { return uniform.index >= 0 && uniform.index < static_cast(artifacts.tProgramBlockIndexToGl.size()) && artifacts.tProgramBlockIndexToGl[uniform.index] < 0; }; + // Member of a block GL can see - a named uniform block, a buffer block, or the + // synthesized atomic-counter block. GL locations are a property of the DEFAULT uniform + // block alone (GL 4.6 core 7.6.1), so these take none. + const auto isNamedBlockMember = [&isGlobalUboMember](const glslang::TObjectReflection& uniform) { + return uniform.index >= 0 && !isGlobalUboMember(uniform); + }; for (Int i = 0; i < tProgramUniformCount; i++) { const auto& uniform = artifacts.program->getUniform(i); if (isGlobalUboMember(uniform) && uniform.stages == 0) { @@ -1069,8 +1075,7 @@ namespace MobileGL::MG_State::GLState { for (const Int i : artifacts.glUniformIndexToTProgram) { const auto& uniform = artifacts.program->getUniform(i); const glslang::TType* type = uniform.getType(); - const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); - if (inNamedBlock) continue; // block members never take glUniform locations + if (isNamedBlockMember(uniform)) continue; // block members never take glUniform locations if (const Int* explicitLocation = findExplicitLocation(uniform.name)) { effectiveLocation[i] = static_cast(*explicitLocation); @@ -1145,20 +1150,23 @@ namespace MobileGL::MG_State::GLState { in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1); } + // Counts ONLY default-block uniforms, which is the whole of what a GL uniform location + // is and the whole of what GL_MAX_UNIFORM_LOCATIONS bounds (GL 4.6 core 7.6.1). A + // named-block member used to be counted here too and used to be handed a location by the + // first-fit pass below, which is a spec violation twice over: glGetUniformLocation must + // answer -1 for it (glGetProgramResourceLocation already did), and every slot it took + // pushed a real default-block uniform one location further up. On a program with a + // buffer block that is exactly how a location EQUAL to the advertised maximum got minted + // - the table's ceiling is raised to hold this count, so one extra block member raised it + // to MAX and the first-fit pass then filled the last slot + // (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit-max, whose compute + // program carries an SSBO; its -max-array sibling ran the pool out and failed to link). Int requiredUniformLocations = deadReservedLocationCount; - // The same count restricted to DEFAULT-BLOCK uniforms, which is the only thing - // GL_MAX_UNIFORM_LOCATIONS bounds. requiredUniformLocations cannot serve: it also carries - // named-block members, which take a slot in this allocator's table (an implementation - // detail) but consume no GL uniform location at all, so a big UBO array would otherwise - // fail a link the spec allows. - Int defaultBlockLocationDemand = deadReservedLocationCount; for (const Int i : artifacts.glUniformIndexToTProgram) { auto& uniform = artifacts.program->getUniform(i); const Uint location = effectiveLocation[i]; const Int locationSpan = GetUniformLocationSpan(uniform); - requiredUniformLocations += locationSpan; - const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); - if (!inNamedBlock) defaultBlockLocationDemand += locationSpan; + if (!isNamedBlockMember(uniform)) requiredUniformLocations += locationSpan; if (location != kNoLocation) { artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1); } @@ -1177,11 +1185,11 @@ namespace MobileGL::MG_State::GLState { // (KHR-GL43.explicit_uniform_location.uniform-loc-negative-link-max-num-of-locations). // A single uniform whose own span passes the ceiling was already rejected above; this is // the aggregate half of the same rule. - if (defaultBlockLocationDemand > static_cast(kMaxUniformLocations)) { + if (requiredUniformLocations > static_cast(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; @@ -1254,6 +1262,10 @@ namespace MobileGL::MG_State::GLState { // is demoted to the first-fit pass below instead of failing the link. for (const Int i : artifacts.glUniformIndexToTProgram) { auto& uniform = artifacts.program->getUniform(i); + // Same rule the effective-location loop applies: a block member has no GL location, + // so it must not reach the first-fit pass either. Its uniformLocations entry stays + // at kNoLocation, which glGetUniformLocation reads back as the -1 the spec wants. + if (isNamedBlockMember(uniform)) continue; if (locationIsSourceExplicit[i]) continue; const Uint location = effectiveLocation[i]; if (location == kNoLocation) { diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index 924b8f83..da9c2e1b 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -3479,3 +3479,104 @@ void main() { } 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); +} From 37a656dedc0742b998f817d9b9dc98092af174a8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:51:12 -0400 Subject: [PATCH 19/23] [Fix, Test] (DirectGLES): name a repaired image uniform after its repair, not after its stage --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 9 +- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 95 ++++---- MobileGL/MG_Backend/DirectGLES/Utils.h | 73 +++--- .../Backend/DirectGLES/EsslShaderPassTest.cpp | 227 ++++++++++++------ 4 files changed, 249 insertions(+), 155 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index c7c2bfd8..3b157bfe 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -6437,11 +6437,12 @@ 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. - // * and it needs the STAGE, because the qualifier it adds is a per-stage decision - // and the rename that keeps two stages from declaring one image uniform - // differently is keyed on it. + // 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, glShaderType, &splitImageUniformCount); + source = SplitReadWriteImageUniforms(source, &splitImageUniformCount); if (splitImageUniformCount != 0) { splitImageUniformStages.push_back({shader->GetShaderStage(), splitImageUniformCount}); } diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index b9af004c..7237a556 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -933,7 +933,7 @@ namespace MobileGL::MG_Backend::DirectGLES { struct ImageUniformDecl { String name; - String stageName; // the stage-tagged name the rewritten declaration takes; empty + 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(...) @@ -1029,21 +1029,6 @@ namespace MobileGL::MG_Backend::DirectGLES { } } // namespace - String ImageStageAliasPrefix(GLenum shaderType) { - switch (shaderType) { - case GL_VERTEX_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Vs_"; - case GL_FRAGMENT_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Fs_"; - case GL_COMPUTE_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Cs_"; - case GL_GEOMETRY_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Gs_"; - case GL_TESS_CONTROL_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Tcs_"; - case GL_TESS_EVALUATION_SHADER: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Tes_"; - // A stage this build does not know. Still a tag of its own rather than the bare - // name, so the anti-collision property below never depends on the switch being - // exhaustive - it only stops being able to say WHICH stage a name came from. - default: return String(IMAGE_STAGE_ALIAS_PREFIX) + "Xs_"; - } - } - namespace { // The digits of an array extent or of an element subscript, or -1 for "not a plain // decimal literal". @@ -1260,7 +1245,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return result; } - String SplitReadWriteImageUniforms(const String& glslCode, GLenum shaderType, Uint* outSplitCount) { + String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -1405,38 +1390,52 @@ namespace MobileGL::MG_Backend::DirectGLES { Vector edits; Vector takenNames; - const String stagePrefix = ImageStageAliasPrefix(shaderType); for (auto& decl : decls) { if (decl.unknownUse) continue; // leave it exactly as it was; no guessing - // EVERY declaration this pass rewrites is also RENAMED, per stage - the - // qualifier it is about to add is a per-stage decision, 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 + // 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). A - // per-stage name means there is no cross-stage variable left to merge. + // shader pack that writes an image in one stage to read it in another). // - // Unconditional, rather than only when another stage is known to declare the same - // name, because this pass sees ONE stage at a time and a conditional rename would - // have to guess. 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. - decl.stageName = MakeImageAliasName(stagePrefix, decl.name, glslCode, takenNames); - takenNames.push_back(decl.stageName); + // 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) { - // Minted from the ALREADY stage-tagged name, so two stages that both split - // the same image do not collide on the write half either. + // 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.stageName, glslCode, takenNames); + MakeImageAliasName(IMAGE_WRITE_ALIAS_PREFIX, decl.aliasName, glslCode, takenNames); takenNames.push_back(decl.writeName); decl.split = true; if (outSplitCount != nullptr) ++*outSplitCount; @@ -1445,28 +1444,28 @@ 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.stageName, + 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.stageName)}); + 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.stageName)}); + BuildImageDeclaration(decl, "readonly", decl.aliasName)}); } } for (const ImageUseSite& site : useSites) { const ImageUniformDecl& decl = decls[site.declIndex]; // 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.stageName.empty()) continue; + if (decl.aliasName.empty()) continue; edits.push_back( - {site.start, site.length, decl.split && site.stores ? decl.writeName : decl.stageName}); + {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 diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index ea9f8543..8d9d0dcc 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -332,15 +332,16 @@ namespace MobileGL::MG_Backend::DirectGLES { 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 (already stage-tagged) name. + // SplitReadWriteImageUniforms); the suffix is the image's own (already access-tagged) name. constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_"; - // Stem of the per-stage name every image declaration SplitReadWriteImageUniforms rewrites - // is renamed under; ImageStageAliasPrefix appends the stage tag and the separator. - constexpr const char* IMAGE_STAGE_ALIAS_PREFIX = "mg_image"; - // "mg_imageVs_", "mg_imageFs_", "mg_imageCs_", ... - the prefix SplitReadWriteImageUniforms - // renames a rewritten image declaration under, so that no two stages can end up declaring - // the same image uniform name with different memory qualifiers. Exposed for the tests. - String ImageStageAliasPrefix(GLenum shaderType); + // 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). @@ -352,31 +353,47 @@ 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, and RENAMES it per stage while doing so: - // * 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 ` and `coherent - // writeonly `, point + // same type, `coherent readonly + // ` and `coherent + // writeonly `, 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 (ImageStageAliasPrefix) 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 + // 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`). A per-stage name leaves no cross-stage variable to merge. Every - // rewritten declaration is renamed, including the readonly half of a split pair; the - // declarations this pass leaves untouched keep their names, and those are exactly the ones - // that already agree across stages. + // 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 @@ -401,16 +418,14 @@ 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. It is downstream of the L2 shader-translation memo (which stores - // what SPIRV-Cross emitted, before any of these text passes), so `shaderType` steering the - // names it mints needs no entry in BuildEsslTranslationKey. + // 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 // application declared. Zero for every shader but a handful, and the only number the // budget note above can be reported with. - String SplitReadWriteImageUniforms(const String& glslCode, GLenum shaderType, - Uint* outSplitCount = nullptr); + String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount = nullptr); // Prefix of the per-sampler float uniform that carries GL_TEXTURE_LOD_BIAS into // the shader (see EmulateTextureLodBias); the suffix is the sampler's own name. constexpr const char* LOD_BIAS_UNIFORM_PREFIX = "mg_lodBias_"; diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index d01298d0..09d03378 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -20,11 +20,12 @@ 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_STAGE_ALIAS_PREFIX; 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::ImageStageAliasPrefix; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats; @@ -44,14 +45,14 @@ namespace { return count; } - // Every fixture below is a fragment shader unless it says otherwise. The pass tags the name - // of every declaration it rewrites with the stage it ran on, so the expectations have to - // spell the same tag. - constexpr GLenum kStage = GL_FRAGMENT_SHADER; - String StageAlias(const String& name) { return ImageStageAliasPrefix(kStage) + name; } - // The writeonly half is minted from the ALREADY stage-tagged name, so it carries both. + // 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(StageAlias(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); @@ -74,13 +75,13 @@ void main() mg_FragColor = loaded; } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); // 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 " + - StageAlias("goku") + ";")) + RwAlias("goku") + ";")) << out; EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent writeonly highp image2D " + SplitWriteAlias("goku") + ";")) @@ -88,7 +89,7 @@ void main() // 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(" + StageAlias("goku") + ",")); + 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,")); @@ -105,7 +106,7 @@ void main() imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); } )"; - const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source, kStage)); + const String out = RemoveLayoutBinding(SplitReadWriteImageUniforms(source)); EXPECT_EQ(CountOf(out, "binding = 5"), 2u); } @@ -120,11 +121,11 @@ void main() mg_FragColor = imageLoad(trunks, ivec3(0)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray " + - StageAlias("trunks") + ";")) + RoAlias("trunks") + ";")) << out; - EXPECT_TRUE(Contains(out, "imageLoad(" + StageAlias("trunks") + ",")); + 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); @@ -138,11 +139,11 @@ void main() imageStore(gohan, ivec2(0), vec4(1.0)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_TRUE( - Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D " + StageAlias("gohan") + ";")) + Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D " + WoAlias("gohan") + ";")) << out; - EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias("gohan") + ",")); + EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("gohan") + ",")); EXPECT_FALSE(Contains(out, "readonly")); EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); } @@ -155,7 +156,7 @@ TEST(SplitReadWriteImageUniformsTest, ExemptFormatsAreLeftCompletelyAlone) { const String source = "#version 320 es\nlayout(binding = 4, " + String(format) + ") uniform highp " + type + " vegeta;\nvoid main()\n{\n imageStore(vegeta, ivec2(0), imageLoad(vegeta, " "ivec2(0)));\n}\n"; - EXPECT_EQ(SplitReadWriteImageUniforms(source, kStage), source) << "format " << format; + EXPECT_EQ(SplitReadWriteImageUniforms(source), source) << "format " << format; } } @@ -172,7 +173,7 @@ void main() // 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, kStage), source); + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); } // The binding of an image array is the array's base; splitting must keep the array on both @@ -185,15 +186,15 @@ void main() imageStore(gohan[1], ivec2(0), imageLoad(gohan[2], ivec2(0))); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent readonly highp image2D " + - StageAlias("gohan") + "[3];")) + 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(" + StageAlias("gohan") + "[2],")); + EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("gohan") + "[2],")); } // The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and @@ -209,20 +210,20 @@ void main() imageStore(goku_hd, ivec2(0), loaded); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + 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. Both are renamed. EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent readonly highp image2D " + - StageAlias("goku") + ";")) + 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 " + - StageAlias("goku_hd") + ";")) + WoAlias("goku_hd") + ";")) << out; - EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias("goku_hd") + ",")); + EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("goku_hd") + ",")); EXPECT_FALSE(Contains(out, SplitWriteAlias("goku") + "_hd")); EXPECT_FALSE(Contains(out, SplitWriteAlias("goku_hd"))); } @@ -237,8 +238,8 @@ void main() imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0))); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); - EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D " + StageAlias("goku") + ";")) + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D " + RwAlias("goku") + ";")) << out; EXPECT_TRUE( Contains(out, "uniform writeonly coherent restrict highp image2D " + SplitWriteAlias("goku") + ";")) @@ -266,14 +267,14 @@ void main() imageStore(storeOnly, ivec2(0), vec4(2.0)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); - EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + StageAlias("goku") + ";")) << out; + const String out = SplitReadWriteImageUniforms(source); + 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 " + StageAlias("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 @@ -294,7 +295,7 @@ void main() mg_FragColor = first + imageLoad(goku, ivec2(0)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_TRUE( Contains(out, "imageStore(" + SplitWriteAlias("goku") + ", ivec2(0), vec4(1.0)); memoryBarrierImage();")) @@ -317,8 +318,8 @@ void main() imageStore(storeOnly, ivec2(0), vec4(1.0)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); - EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + StageAlias("storeOnly") + ";")) << out; + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + WoAlias("storeOnly") + ";")) << out; EXPECT_FALSE(Contains(out, "memoryBarrierImage")) << out; } @@ -333,8 +334,8 @@ void main() imageStore(gohan[1], ivec2(0), max(imageLoad(gohan[2], ivec2(0)), vec4(0.5))); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); - EXPECT_TRUE(Contains(out, "max(imageLoad(" + StageAlias("gohan") + + const String out = SplitReadWriteImageUniforms(source); + EXPECT_TRUE(Contains(out, "max(imageLoad(" + RwAlias("gohan") + "[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();")) << out; EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 1u) << out; @@ -359,7 +360,7 @@ void main() } )"; Uint splitCount = 99u; - SplitReadWriteImageUniforms(twoSplits, kStage, &splitCount); + SplitReadWriteImageUniforms(twoSplits, &splitCount); EXPECT_EQ(splitCount, 2u) << "only the read+write pair counts; the store-only repair adds no uniform"; // Every early return has to write the count too, or a caller reads whatever was there before. @@ -371,7 +372,7 @@ void main() } )"; splitCount = 99u; - SplitReadWriteImageUniforms(noImages, kStage, &splitCount); + SplitReadWriteImageUniforms(noImages, &splitCount); EXPECT_EQ(splitCount, 0u); } @@ -386,20 +387,20 @@ void main() mg_FragColor = vec4(float(imageSize(sizeOnly).x)); } )"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D " + - StageAlias("sizeOnly") + ";")) + RoAlias("sizeOnly") + ";")) << out; // The rename has to reach imageSize too, or the declaration and its only use stop agreeing. - EXPECT_TRUE(Contains(out, "imageSize(" + StageAlias("sizeOnly") + ")")) << out; + EXPECT_TRUE(Contains(out, "imageSize(" + RoAlias("sizeOnly") + ")")) << out; EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX)); } // Neither minted name may land on an identifier the shader already uses - and there are two of -// them now, the stage-tagged name of the repaired declaration and the writeonly half built on +// 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 = StageAlias("taken"); + 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" @@ -408,7 +409,7 @@ TEST(SplitReadWriteImageUniformsTest, AliasNamesAvoidExistingIdentifiers) { ";\nvoid main()\n{\n" " imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + " + stageCollision + " + " + writeCollision + ");\n}\n"; - const String out = SplitReadWriteImageUniforms(source, kStage); + const String out = SplitReadWriteImageUniforms(source); EXPECT_FALSE(Contains(out, "image2D " + stageCollision + ";")) << out; EXPECT_FALSE(Contains(out, "image2D " + writeCollision + ";")) << out; @@ -435,7 +436,7 @@ void main() // 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, kStage), source); + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); } TEST(SplitReadWriteImageUniformsTest, ShaderWithoutImagesIsReturnedUnchanged) { @@ -447,7 +448,7 @@ void main() mg_FragColor = texture(goku, vec2(0.5)); } )"; - EXPECT_EQ(SplitReadWriteImageUniforms(source, kStage), source); + EXPECT_EQ(SplitReadWriteImageUniforms(source), source); } // The defect the rename exists for. The pass sees ONE stage at a time and picks the memory @@ -455,9 +456,9 @@ void main() // 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). Stage-tagged names leave -// nothing to merge. -TEST(SplitReadWriteImageUniformsTest, TheSameImageGetsADifferentNameInEachStage) { +// 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() @@ -474,11 +475,11 @@ void main() mg_FragColor = imageLoad(g_image, ivec2(0)); } )"; - const String vsOut = SplitReadWriteImageUniforms(vertexSource, GL_VERTEX_SHADER); - const String fsOut = SplitReadWriteImageUniforms(fragmentSource, GL_FRAGMENT_SHADER); + const String vsOut = SplitReadWriteImageUniforms(vertexSource); + const String fsOut = SplitReadWriteImageUniforms(fragmentSource); - const String vsName = ImageStageAliasPrefix(GL_VERTEX_SHADER) + "g_image"; - const String fsName = ImageStageAliasPrefix(GL_FRAGMENT_SHADER) + "g_image"; + 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; @@ -493,21 +494,62 @@ void main() EXPECT_TRUE(Contains(fsOut, "binding = 0")); } -// Every stage gets a tag of its own, including the ones a fragment/vertex pair never exercises. -TEST(SplitReadWriteImageUniformsTest, EveryStageTagIsDistinct) { - const GLenum stages[] = {GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, GL_COMPUTE_SHADER, - GL_GEOMETRY_SHADER, GL_TESS_CONTROL_SHADER, GL_TESS_EVALUATION_SHADER}; - Vector prefixes; - for (const GLenum stage : stages) { - const String prefix = ImageStageAliasPrefix(stage); - EXPECT_EQ(prefix.rfind(IMAGE_STAGE_ALIAS_PREFIX, 0), 0u) << prefix; +// 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 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 : prefixes) { + 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; } - prefixes.push_back(prefix); + seenPrefixes.push_back(prefix); } } @@ -690,17 +732,17 @@ void main() } )"; String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 6})}); - out = SplitReadWriteImageUniforms(out, kStage); + 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 " + StageAlias(Elem("g_image", 0)) + ";")) << out; - EXPECT_TRUE(Contains(out, "writeonly highp image2D " + StageAlias(Elem("g_image", 1)) + ";")) << out; - EXPECT_TRUE(Contains(out, "imageStore(" + StageAlias(Elem("g_image", 1)) + ", ivec2(0), imageLoad(" + - StageAlias(Elem("g_image", 0)) + ", ivec2(0)))")) + 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; @@ -718,17 +760,54 @@ void main() } )"; String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 9})}); - out = SplitReadWriteImageUniforms(out, kStage); + 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 " + StageAlias(Elem("g_image", 1)) + ";")) << out; - EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WriteAlias(StageAlias(Elem("g_image", 1))) + ";")) + 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 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 // @@ -971,7 +1050,7 @@ layout(binding = 3) uniform writeonly highp uimage2D uni_image; void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } )"; String out = BakeImageFormatQualifiers(source, {{"uni_image", "r8ui"}}); - out = SplitReadWriteImageUniforms(out, kStage); + out = SplitReadWriteImageUniforms(out); out = RemoveLayoutBinding(out); EXPECT_TRUE(Contains(out, "r8ui")) << out; EXPECT_TRUE(Contains(out, "binding = 3")) << out; From a4c11f260345cb74721f106341aed095c52a564a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:54:39 -0400 Subject: [PATCH 20/23] [Fix, Test] (DirectGLES): arm the image-format widening for a baked format on every driver --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 56 +++-- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Scenarios/FormatlessImageBakeScenario.cpp | 211 ++++++++++++++++++ 3 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index cf8a4134..40f9a5dc 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5268,31 +5268,41 @@ namespace MobileGL::MG_Backend::DirectGLES { if (boundFormat == 0) continue; if (!MG_Util::ShaderTranspiler::ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(boundFormat)) { - if (!g_GLESCapabilities.SupportsExtendedImageFormats) { - // Outside the GLSL ES core set and with no GL_NV_image_formats to spell - // it. If the format widens exactly it is still baked HERE, narrow, and - // WidenImageFormatsForEssl re-declares it in its core carrier immediately - // afterwards - both routes into that pass end in the same place. - // - // If it does not widen 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. - if (!ImageFormatWillBeWidened(boundFormat)) { - 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; - } + // 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. + // 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; } diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 7abdeec1..476dca96 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -88,6 +88,7 @@ add_executable(MobileGLIntegrationTest Scenarios/IoBlockNameCollisionScenario.cpp Scenarios/TessellationDrawModeScenario.cpp Scenarios/GeometryDrawModeScenario.cpp + Scenarios/FormatlessImageBakeScenario.cpp Scenarios/FragmentOutputArrayIndexScenario.cpp Scenarios/BufferTextureScenario.cpp Scenarios/VertexAttribBindingScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.cpp new file mode 100644 index 00000000..68b9a186 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.cpp @@ -0,0 +1,211 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A FORMAT-LESS IMAGE UNIFORM WHOSE UNIT HOLDS A NON-CORE FORMAT. +// +// GLSL 4.20 lets a write-only image uniform omit its layout format; GLSL ES demands one, so +// DirectGLES BAKES the format of whatever glBindImageTexture put on the unit into the +// declaration. When that format is outside the GLSL ES core thirteen, the bake alone is not +// enough - the baked declaration then has to go through the same channel-widening +// WidenImageFormatsForEssl gives a DECLARED non-core format (see NonCoreImageFormatScenario for +// the widening itself). +// +// The two routes had different arming. The declared route armed the widening on the format +// alone; the baked route armed it only when the driver lacked GL_NV_image_formats. That reads +// like an optimisation and is not one: SPIRV-Cross throws for its is_desktop_only_format set the +// moment it targets ESSL, whatever the driver would have accepted, so on a driver that HAS the +// extension the shader half of the widening stayed switched off while TextureImpl's storage/bind +// half - which keys on SpirvCrossCanPrintEsslImageFormat, not on the driver bit - still ran. The +// stage threw, the program linked without it, and every dispatch silently did nothing. +// +// KHR-GL43.stencil_texturing.functional is where it surfaced: its compute half writes through a +// format-less `uimage2D` bound to an R8UI texture, and returned zeros for every texel. +// +// DISCRIMINATING ONLY WHERE THE DRIVER ADVERTISES GL_NV_image_formats - Mesa does, which is what +// the software lanes run and where this was found. On Adreno 830 and both Malis the extension is +// absent, the old code already armed the widening, and these cases pass before and after; they +// are kept running there as a guard against the opposite mistake. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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 expected(kExtent * kExtent); + for (int i = 0; i < kExtent * kExtent; ++i) { + expected[i] = static_cast(1 + i); + } + + // Source: a core-format integer texture holding 1..64. + std::vector sourceBytes(kExtent * kExtent); + for (int i = 0; i < kExtent * kExtent; ++i) { + sourceBytes[i] = static_cast(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 zeros(static_cast(kExtent) * kExtent * 8, 0); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, uploadFormat, uploadType, zeros.data()); + ASSERT_EQ(glGetError(), static_cast(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(GL_NO_ERROR)) << "binding"; + + glDispatchCompute(kExtent, kExtent, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "dispatch"; + + std::vector 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(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 From 518e9c7796f0eb2cabef159307d452cc519771f8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:57:59 -0400 Subject: [PATCH 21/23] [Fix, Test] (DirectGLES): read an image array subscript unsigned literal as the element index it is --- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 16 ++++++- .../Backend/DirectGLES/EsslShaderPassTest.cpp | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 7237a556..cfdadae7 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -1032,10 +1032,24 @@ namespace MobileGL::MG_Backend::DirectGLES { 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 (const char c : text) { + 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 diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index 09d03378..831532e1 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -685,6 +685,50 @@ void main() 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 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 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. From a79eadd724c82698368627a55ce575afe441985c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 06:57:59 -0400 Subject: [PATCH 22/23] [Fix, Test] (ShaderTranspiler): bound the whole loop nest a resource-array index marks for unrolling --- .../LegalizeResourceArrayIndexTest.cpp | 40 ++++++++++ .../LegalizeResourceArrayIndexPass.cpp | 76 +++++++++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp index 19cfb6c6..1c9cc7c3 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp @@ -284,6 +284,23 @@ void main() { 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 @@ -426,6 +443,29 @@ TEST(LegalizeResourceArrayIndexPass, FoldsALoopIndexedImageArray) { } } +// 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 input = CompileCompute(kNestedLoopIndexedImageArray); + ASSERT_FALSE(input.empty()); + EXPECT_TRUE(HasDynamicImageArrayIndex(input)); + + Vector 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 input = CompileCompute(kUniformIndexedImageArray); ASSERT_FALSE(input.empty()); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp index 4d729fd6..fb0d5fbb 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp @@ -48,11 +48,15 @@ namespace MobileGL { // 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 or of images iterates at - // most GL_MAX_*_SHADER_STORAGE_BLOCKS / GL_MAX_*_IMAGE_UNIFORMS times in any + // 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 { @@ -286,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; @@ -301,10 +310,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 @@ -345,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(spv::LoopControlMask::MaskNone)) { - continue; + break; } mergeInst->SetOperand( 2, {static_cast(spv::LoopControlMask::Unroll)}); + nestIterations *= iterations; modified = true; } } From 2a902ff58cb837701e5401dae2a1a3e9566124ff Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 07:04:19 -0400 Subject: [PATCH 23/23] [Fix, Test] (ShaderTranspiler): bound the whole loop nest a fragment-output index marks for unrolling --- MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 41 +++++++++++++ .../LegalizeFragmentOutputIndexPass.cpp | 61 +++++++++++++++---- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 9b2799cc..05c4bff8 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -3042,6 +3042,47 @@ void main() { << "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl; } +// Marking a loop for unrolling means marking every loop enclosing it - SPIRV-Tools only unrolls +// innermost loops - and the copies those levels produce MULTIPLY, so bounding each loop on its +// own bounds nothing. This nest is the OIT shape wrapped in a tile walk: 64 x 64 x 2, every level +// individually inside kMaxUnrolledIterations, and its product is not. Spending the budget as the +// walk climbs stops at the innermost level; the switch lowering, whose cost is the output array's +// length rather than the trip counts, legalizes whatever the unroll no longer reaches. The same +// defect was measured first on LegalizeResourceArrayIndexPass, which the image half of that pass +// made reachable; this walk is its twin and is fixed the same way. +TEST_F(ProgramUtilTest, ALoopNestAroundAFragmentOutputIndexIsBoundedAsAWhole) { + using namespace MG_Util::ShaderTranspiler; + + const Vector 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 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. diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp index ecfd89eb..620a17fc 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp @@ -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(spv::LoopControlMask::MaskNone)) { - continue; + break; } mergeInst->SetOperand( 2, {static_cast(spv::LoopControlMask::Unroll)}); + nestIterations *= iterations; modified = true; } }