diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ab628c8..6c5c5b29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -285,6 +285,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp 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/PrivateToEntryLocalPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index c9b30f2f..f08c23d3 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -2195,7 +2195,15 @@ namespace MobileGL::MG_Backend::DirectGLES { twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask || twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount || twin->GetShaderStorageBlockBindingSignature() != - ComputeShaderStorageBlockBindingSignature(*currentProgram)) { + ComputeShaderStorageBlockBindingSignature(*currentProgram) || + // A fourth of the same shape, and the reason glBindImageTexture itself does + // nothing: GLSL ES demands a format layout qualifier on an image where desktop + // GLSL lets a writeonly declaration omit one, so a format-less declaration is + // compiled against the format the application BOUND, and a rebind to a + // 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->SyncToBackend(currentProgram); } g_currentDrawFrontendProgram = currentProgram.get(); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 56bcb119..67671e9c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -4497,6 +4497,161 @@ namespace MobileGL::MG_Backend::DirectGLES { return signature; } + namespace { + // The GL internal format bound to an image unit right now. GL_NONE for a unit + // outside the frontend's array, which cannot be addressed at all. + Uint BoundImageUnitFormat(Int unit) { + if (unit < 0 || unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) return 0; + return static_cast(MG_State::pGLContext->GetImageTextureBinding(unit).Format); + } + + // Combines one (unit, format) pair into a running digest. Commutative, so the order + // the uniforms are walked in cannot change the answer, and mixed rather than summed + // so a unit and a format cannot trade places between two pairs and cancel out. + Uint64 MixImageUnitFormat(Uint64 signature, Int unit, Uint format) { + Uint64 entry = static_cast(static_cast(unit)) + 0x9e3779b97f4a7c15ull; + entry ^= static_cast(format) + 0xbf58476d1ce4e5b9ull + (entry << 6) + (entry >> 2); + return signature + entry; + } + + // Reflection names an array uniform after its first element ("g_image[0]") at every + // location it spans; SPIR-V names the variable once, without the subscript. This is + // the name both sides agree on. + String ImageUniformBaseName(const String& reflectionName) { + if (reflectionName.size() >= 3 && reflectionName.compare(reflectionName.size() - 3, 3, "[0]") == 0) { + return reflectionName.substr(0, reflectionName.size() - 3); + } + return reflectionName; + } + + // Whether a glslang layout format is one GLSL ES has in core; the rest reach ES only + // through GL_NV_image_formats. Asked of DECLARED formats, which this backend passes + // through untouched - the emitted ESSL still has to be legal for the driver. + Bool IsCoreEsslLayoutFormat(glslang::TLayoutFormat format) { + switch (format) { + case glslang::ElfRgba32f: + case glslang::ElfRgba16f: + case glslang::ElfR32f: + case glslang::ElfRgba8: + case glslang::ElfRgba8Snorm: + case glslang::ElfRgba32i: + case glslang::ElfRgba16i: + case glslang::ElfRgba8i: + case glslang::ElfR32i: + case glslang::ElfRgba32ui: + case glslang::ElfRgba16ui: + case glslang::ElfRgba8ui: + case glslang::ElfR32ui: + return true; + default: + return false; + } + } + } // namespace + + // What the format bake needs from the frontend, collected in one walk of the uniform + // reflection: which image uniforms declared NO format (the only ones a bake may touch - + // a declared format is authoritative and stays), what the units they address currently + // hold, and whether any format in play - declared or baked - is outside the ES core set. + ImageFormatBakeInputs CollectImageFormatBakeInputs( + const MG_State::GLState::ProgramObject& stateProgramObject) { + ImageFormatBakeInputs inputs; + 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; + const glslang::TType* type = stateProgramObject.GetUniformTType(loc); + if (type == nullptr) continue; + if (type->getQualifier().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(type->getQualifier().getFormat())) { + inputs.needsExtendedImageFormats = true; + } + continue; + } + const Int unit = stateProgramObject.GetUniformSamplerOrImageUnitIndex(loc); + if (unit < 0 || unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) continue; + const Uint boundFormat = BoundImageUnitFormat(unit); + + // Every format-less uniform contributes to the rebuild key, including one whose + // unit holds nothing yet: an image bound for the first time AFTER the link has + // to move the key, or the program built against "nothing bound" would never be + // rebuilt against the real format. + inputs.units.push_back(unit); + inputs.signature = MixImageUnitFormat(inputs.signature, unit, boundFormat); + + 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); + continue; + } + inputs.needsExtendedImageFormats = true; + } + const String baseName = ImageUniformBaseName(name); + const auto existing = inputs.glFormatByUniformName.find(baseName); + if (existing == inputs.glFormatByUniformName.end()) { + inputs.glFormatByUniformName.emplace(baseName, boundFormat); + } else if (existing->second != boundFormat) { + // An ARRAY whose elements were pointed at units holding different formats. + // One declaration carries one qualifier, so there is no spelling for it, and + // the uniform is left format-less rather than given a format that is wrong + // for all but one element. Marked in place with GL_NONE and swept below - + // never by erasing here, because the entry is reached again by the array's + // remaining elements and a flat hash map must not be mutated structurally + // while an iterator into it is live. + existing->second = 0; + } + } + for (const auto& entry : inputs.glFormatByUniformName) { + if (entry.second == 0) inputs.conflictedNames.push_back(entry.first); + } + for (const auto& conflicted : inputs.conflictedNames) { + inputs.glFormatByUniformName.erase(conflicted); + } + // Split off the ones SPIRV-Cross will not print. They cannot go through the module - + // it throws for them when targeting ESSL, and the stage is lost - so they are spelled + // into the emitted text instead. Collected first, erased after, because a flat hash + // map must not be restructured while it is being walked. + Vector textCompleted; + for (const auto& entry : inputs.glFormatByUniformName) { + if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(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)); + textCompleted.push_back(entry.first); + } + for (const auto& name : textCompleted) { + inputs.glFormatByUniformName.erase(name); + } + return inputs; + } + + Uint64 BackendProgramObjectImpl::ComputeImageUnitFormatSignature() const { + if (m_formatlessImageUnits.empty()) return 0; // all but a handful of programs + Uint64 signature = 0; + for (const Int unit : m_formatlessImageUnits) { + signature = MixImageUnitFormat(signature, unit, BoundImageUnitFormat(unit)); + } + return signature; + } + + Bool BackendProgramObjectImpl::ImageUnitFormatsStillMatch() const { + if (m_formatlessImageUnits.empty()) return m_imageUnitFormatSignature == 0; + return ComputeImageUnitFormatSignature() == m_imageUnitFormatSignature; + } + void BackendProgramObjectImpl::SyncToBackend( const SharedPtr& stateProgramObject) { #ifdef TRACY_ENABLE @@ -4537,6 +4692,18 @@ namespace MobileGL::MG_Backend::DirectGLES { // this build current - the draw path compares the signature and rebuilds on a change. const auto& storageBlockBindingOverrides = stateProgramObject->GetShaderStorageBlockBindingOverrides(); m_shaderStorageBlockBindingSignature = ComputeShaderStorageBlockBindingSignature(*stateProgramObject); + // 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). + // Taken BEFORE the transpile loop so both the bake and the key see one snapshot. + const ImageFormatBakeInputs imageFormatBake = CollectImageFormatBakeInputs(*stateProgramObject); + m_formatlessImageUnits = imageFormatBake.units; + m_imageUnitFormatSignature = imageFormatBake.signature; + for (const auto& conflicted : imageFormatBake.conflictedNames) { + MGLOG_D("Image uniform '%s' of program %u declares no format and its elements address units with " + "different bound formats; left format-less.", + conflicted.c_str(), stateProgramObject->GetExternalIndex()); + } // Detach all existing shaders GLint attachedCount = 0; @@ -4719,6 +4886,25 @@ namespace MobileGL::MG_Backend::DirectGLES { effectiveSpirv = &arrayImageSpirv; } + // 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 + // application bound to its unit - the one GL's format-class rules make correct - + // so SPIRV-Cross prints a qualifier. AFTER the 1D-array lowering above, which + // also rewrites image types, so this one is looking at the final shapes. + // + // Gated on the module actually declaring one: the map is empty for every program + // 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; + if (!imageFormatBake.glFormatByUniformName.empty() && + MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresFormatlessStorageImage(*effectiveSpirv) && + MG_Util::ShaderTranspiler::ShaderCompiler::BakeImageFormatsForEssl( + *effectiveSpirv, imageFormatBake.glFormatByUniformName, imageFormatSpirv) && + !imageFormatSpirv.empty()) { + effectiveSpirv = &imageFormatSpirv; + } + // 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 @@ -4786,8 +4972,24 @@ namespace MobileGL::MG_Backend::DirectGLES { // because a header concern reads better before the body ones. source = RetargetTextureBufferExtension(std::move(source), g_GLESCapabilities.TextureBufferSupport); + // The other header-level rewrite, and next to that one for the same reason. The + // formats it covers are both the ones the bake above put into the module and the + // ones the application declared itself - either can be outside the thirteen GLSL + // ES has in core, and neither reaches the driver without this directive. + source = RequestExtendedImageFormats(std::move(source), + imageFormatBake.needsExtendedImageFormats && + g_GLESCapabilities.SupportsExtendedImageFormats); source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject); + // The completion half of the format bake, for the formats SPIRV-Cross throws on + // rather than prints (r8ui and the rest of its desktop-only set). Empty for every + // program whose format-less images bound a format the module could carry, which + // is the normal case - those were baked into the SPIR-V above and this pass finds + // their declarations already qualified. AFTER the rebind, so the layout qualifier + // it edits is the one that already exists; BEFORE the split and the binding + // strip, so both halves of a split image inherit the format. + source = BakeImageFormatQualifiers(std::move(source), + imageFormatBake.esslFormatQualifierByUniformName); // 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/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 33669ed5..7cb98587 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -1122,6 +1122,28 @@ namespace MobileGL::MG_Backend::DirectGLES { // stale as one built before a relink - while the sampler half, which really is // re-issued per draw, needs nothing of the sort. Uint32 GetSyncedImageUnitVersion() const { return m_syncedImageUnitVersion; } + // Whether the (unit, bound format) pairs this program's FORMAT-LESS image uniforms + // resolve to are still the ones its ESSL was generated against. + // + // A fourth condition of the same family as the three above, and the only one that + // reads live state rather than a program-side counter, because that is where the + // dependency actually is. GLSL ES requires a format layout qualifier on every image + // where desktop GLSL lets a writeonly declaration omit one, and the only correct + // qualifier is whatever glBindImageTexture named - so a declaration with no format + // is compiled against the BINDING, and a rebind to a different format makes the + // built program wrong. Keyed on the units the program's own images address (cached + // at sync, since a unit can only move by glUniform1i, which bumps the image-unit + // version above and forces a re-sync anyway), so the cost on a program with no + // format-less image - which is all but a handful - is one empty-vector test. + // + // Deliberately NOT reached from glBindImageTexture: that entry point must never + // trigger a build (same constraint as glShaderStorageBlockBinding). It moves the + // state and this comparison notices at the next Prepare, which is also what makes + // an image first bound AFTER link work. + Bool ImageUnitFormatsStillMatch() const; + // The value ImageUnitFormatsStillMatch() compares against, recomputed from live + // image-unit state. 0 when the program has no format-less image uniform. + Uint64 ComputeImageUnitFormatSignature() const; private: void CacheResourceLocations(const SharedPtr& stateProgramObject); @@ -1155,6 +1177,12 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::UboRingAllocation m_globalUboRingAllocation; Uint32 m_syncedLinkVersion = ~0u; Uint32 m_syncedImageUnitVersion = ~0u; + // Image units addressed by the program's FORMAT-LESS image uniforms, and the digest + // of the (unit, format) pairs the generated ESSL baked. Empty/0 for every program + // that declares a format on all of its images, which is the overwhelming majority - + // and what keeps the per-draw comparison free for them. + Vector m_formatlessImageUnits; + Uint64 m_imageUnitFormatSignature = 0; SamplerPassMemo m_samplerPassMemo; }; @@ -1198,6 +1226,40 @@ namespace MobileGL::MG_Backend::DirectGLES { // already has costs nothing. 0 when nothing was ever rebound. Uint64 ComputeShaderStorageBlockBindingSignature( const MG_State::GLState::ProgramObject& stateProgramObject); + + // Everything the image-format bake needs from one walk of a program's uniform + // reflection. GLSL ES requires a format layout qualifier on every image uniform; + // desktop GLSL lets a writeonly (or readonly) declaration omit one, and the only + // format that is CORRECT to substitute is whatever glBindImageTexture named for the + // unit that uniform addresses - so the transpile bakes it in and the build is keyed + // on it. + struct ImageFormatBakeInputs { + // Uniform name (SPIR-V spelling, i.e. an array named once, unsubscripted) to the GL + // internal format to bake. Holds only uniforms that DECLARED no format; a declared + // one is authoritative and is never overridden. + UnorderedMap glFormatByUniformName; + // The same uniforms whose format SPIRV-Cross REFUSES to print for ESSL (it throws on + // its desktop-only set, which loses the stage), paired with the ESSL spelling to + // write into the emitted declaration instead. Disjoint from the map above by + // construction: a format is baked into the module or completed in the text, never + // both. r8ui - the stencil half of the packed_depth_stencil case - lands here. + UnorderedMap esslFormatQualifierByUniformName; + // Units those uniforms address, kept so the draw path can re-read their formats + // without walking the reflection again. + Vector units; + // Digest of the (unit, format) pairs above. 0 when the program has no format-less + // image uniform, which is all but a handful. + Uint64 signature = 0; + // Array uniforms whose elements resolved to units holding DIFFERENT formats: one + // declaration carries one qualifier, so there is nothing correct to bake and they + // are dropped from the map above. Kept for diagnostics. + Vector conflictedNames; + // 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; + }; + ImageFormatBakeInputs CollectImageFormatBakeInputs( + const MG_State::GLState::ProgramObject& stateProgramObject); } // namespace PrgramImpl namespace SamplerImpl { diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 6251f10c..6f97ef4f 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -535,6 +535,92 @@ namespace MobileGL::MG_Backend::DirectGLES { return glslCode; } + String RequestExtendedImageFormats(String glslCode, Bool needed) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + // GLSL ES core has thirteen image formats; GL has forty. SPIRV-Cross prints whatever + // format the OpTypeImage carries and asks for no extension for it, so an r8ui or + // rg16f image - declared as such, or baked from the bound one - reaches the driver as + // a format its core language does not know. GL_NV_image_formats is the only thing + // that adds them, and it has to be requested by name. + // + // The caller decides `needed`: it knows which formats are in play (from the uniform + // reflection and the image-unit bindings) and whether the driver advertises the + // extension at all - `#extension` on an unadvertised name is itself a hard error, so + // this must never be emitted speculatively. + static constexpr const char* kDirective = "#extension GL_NV_image_formats : require\n"; + static constexpr const char* kExtName = "GL_NV_image_formats"; + if (!needed || glslCode.find(kExtName) != String::npos) { + return glslCode; + } + // After the #version line, which must stay first. Everything else about the header is + // order-insensitive, and ForceSupporterOutput's scan for the LAST #extension + // directive still finds whichever one that is. + const SizeT versionPos = glslCode.find("#version"); + if (versionPos == String::npos) { + return kDirective + glslCode; + } + const SizeT lineEnd = glslCode.find('\n', versionPos); + if (lineEnd == String::npos) { + return glslCode + "\n" + kDirective; + } + glslCode.insert(lineEnd + 1, kDirective); + return glslCode; + } + + String BakeImageFormatQualifiers(String glslCode, + const UnorderedMap& esslFormatByUniformName) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (esslFormatByUniformName.empty() || glslCode.find("image") == String::npos) { + return glslCode; + } + // Same declaration shape RebindImageUniformsToFrontendUnits matches, and for the same + // reason: one line, one image uniform, the name in group 3. + static const std::regex imageDeclRegex( + R"((layout\s*\(([^)]*)\)\s*)?uniform\s+(?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*[iu]?image[A-Za-z0-9]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\[[^\]]*\])?\s*;)"); + // Every image format spelling GLSL has, so a declaration that already carries one is + // recognised whatever it says - the caller's map is consulted only for declarations + // with NO format, never to override a written one. + static const std::regex existingFormatRegex( + R"(\b(rgba32f|rgba16f|rg32f|rg16f|r11f_g11f_b10f|r32f|r16f|rgba16|rgb10_a2|rg16|rg8|r16|r8|rgba16_snorm|rgba8_snorm|rg16_snorm|rg8_snorm|r16_snorm|r8_snorm|rgba32i|rgba16i|rgba8i|rg32i|rg16i|rg8i|r32i|r16i|r8i|rgba32ui|rgba16ui|rgba8ui|rgb10_a2ui|rg32ui|rg16ui|rg8ui|r32ui|r16ui|r8ui)\b)"); + + String result; + result.reserve(glslCode.size()); + SizeT lineStart = 0; + while (lineStart <= glslCode.size()) { + const SizeT lineEnd = glslCode.find('\n', lineStart); + const Bool lastLine = lineEnd == String::npos; + String line = glslCode.substr(lineStart, lastLine ? String::npos : lineEnd - lineStart); + + std::smatch match; + if (std::regex_search(line, match, imageDeclRegex)) { + const String name = match[3].str(); + const auto formatIt = esslFormatByUniformName.find(name); + const String layoutContents = match[2].matched ? match[2].str() : String(); + if (formatIt != esslFormatByUniformName.end() && !formatIt->second.empty() && + !std::regex_search(layoutContents, existingFormatRegex)) { + if (match[1].matched) { + const SizeT layoutOpen = line.find('(', match.position(1)); + line.insert(layoutOpen + 1, formatIt->second + ", "); + } else { + line.insert(match.position(0), "layout(" + formatIt->second + ") "); + } + } + } + + result += line; + if (lastLine) { + break; + } + result += '\n'; + lineStart = lineEnd + 1; + } + return result; + } + String RemoveLayoutBinding(const String& glslCode) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 25bc6c55..d44b3c67 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -137,6 +137,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // ES 3.2 needs no directive at all and an EXT driver already has the right one. String RetargetTextureBufferExtension(String glslCode, MG_External::GLESCapabilities::TextureBufferTier tier); + // Adds `#extension GL_NV_image_formats : require` when the shader carries an image + // format qualifier GLSL ES has no core spelling for. SPIRV-Cross prints the format and + // asks for nothing, so the request has to be made here. `needed` is the caller's answer, + // because only it knows which formats are in play AND whether the driver advertises the + // extension - requesting an unadvertised extension is itself a compile error, so this is + // never emitted speculatively. A no-op when not needed or already present. + String RequestExtendedImageFormats(String glslCode, Bool needed); + // Writes a format layout qualifier into the image declarations named in + // `esslFormatByUniformName` that still have none. The completion half of the image-format + // bake, and ONLY that: the SPIR-V pass (BakeImageFormatsPass) is what normally puts the + // format in, but SPIRV-Cross throws rather than printing the formats it calls + // desktop-only when it targets ESSL - r8ui among them, which is what the stencil half of + // KHR-GL4x.packed_depth_stencil.stencil_texturing binds - and a throw loses the whole + // stage. So those formats stay out of the module and are spelled here instead, on the + // emitted text, where nothing can refuse them. + // + // Declarations that already carry a format are left exactly as they are, whoever wrote + // it. Must run before RemoveLayoutBinding, which is where an image's layout qualifier + // stops being safe to edit by hand. + 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. diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index d33d9dd5..ceb469b7 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -70,6 +70,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ProgramPipelineScenario.cpp Scenarios/ImageLoadStoreSsoScenario.cpp Scenarios/ImageTargetKindScenario.cpp + Scenarios/ImageFormatQualifierScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp Scenarios/FragmentOutputArrayIndexScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageFormatQualifierScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageFormatQualifierScenario.cpp new file mode 100644 index 00000000..3ef3bc35 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageFormatQualifierScenario.cpp @@ -0,0 +1,314 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageFormatQualifierScenario.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 UNIFORM THAT DECLARES NO FORMAT. +// +// Desktop GLSL 4.2 lets a writeonly image declaration omit its format layout qualifier: +// +// writeonly uniform uimage2D uni_image; // legal desktop GLSL +// +// GLSL ES has no such relaxation; every image uniform must carry one, and Adreno says so as "all +// images have to define layout format", which fails the whole program. That is what took the +// compute half of KHR-GL4x.packed_depth_stencil.stencil_texturing. +// +// The only qualifier that is CORRECT to substitute is whatever glBindImageTexture named for the +// unit that uniform addresses - GL requires the qualifier, the bind format and the texture's +// internal format to belong to one format class - so the format is not knowable when the shader +// is compiled, only when it is drawn with. Espryt therefore BAKES it into the program it +// generates and keys that program on the (unit, format) pairs it baked +// (BackendProgramObjectImpl::ImageUnitFormatsStillMatch, MG_Backend/DirectGLES). +// +// Three separate things follow from "the program is built against live binding state", and each +// one is a case below: +// +// 1. the format reaches the shader at all, so the store lands where the texture is (Writes); +// 2. binding a DIFFERENT format to the same unit rebuilds the program, rather than reusing one +// compiled against the old format (RebindToADifferentFormatRebuilds); +// 3. an image bound for the FIRST time after the link works, i.e. the program built against +// "nothing bound yet" is not the one the dispatch runs (FirstBindAfterLinkRebuilds). +// +// Magma needs none of this - Vulkan takes an Unknown-format storage image given +// shaderStorageImageWriteWithoutFormat, and the view format is resolved from the same bind state +// at descriptor time - so every case here runs on both backends and must agree, which is what +// makes the ES-only machinery falsifiable rather than merely exercised. + +#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 image unit is deliberately NOT 0 and the uniform declares no binding, so the unit + // has to travel through glUniform1i and be baked into the ESSL alongside the format - + // the two bakes share a rebuild key and a bug in either shows up as the wrong texel. + constexpr GLint kImageUnit = 1; + + // KHR-GL4x.packed_depth_stencil.stencil_texturing's own image declaration, verbatim. + const char* kStoreSource = R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +writeonly uniform uimage2D uni_image; + +void main() +{ + imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(gl_GlobalInvocationID.x + 100u, 0u, 0u, 0u)); +} +)"; + + class ImageFormatQualifierScenario : 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 > kImageUnit && maxComputeImageUniforms >= 1; + } + + 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; + } + + GLuint MakeTexture(GLenum internalFormat) { + 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; + } + // Seeded to a value no dispatch writes, so "the store never happened" and "the + // store wrote the right thing" cannot be confused. + const std::vector zeros(static_cast(kExtent) * kExtent * 4u, 0u); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, + internalFormat == GL_RGBA32UI ? GL_RGBA_INTEGER : GL_RED_INTEGER, GL_UNSIGNED_INT, + zeros.data()); + while (glGetError() != GL_NO_ERROR) { + } + return texture; + } + + // Texel (x, 0) of the texture's red channel, read back through the GL frontend rather + // than through a second image uniform: a defect in the format bake would be shared by + // a reader declared the same way and could cancel itself out. + GLuint ReadRedTexel(GLuint texture, GLenum internalFormat, int x) { + const bool rgba = internalFormat == GL_RGBA32UI; + std::vector texels(static_cast(kExtent) * kExtent * (rgba ? 4u : 1u), + 0xFFFFFFFFu); + glBindTexture(GL_TEXTURE_2D, texture); + glGetTexImage(GL_TEXTURE_2D, 0, rgba ? GL_RGBA_INTEGER : GL_RED_INTEGER, GL_UNSIGNED_INT, + texels.data()); + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << "reading the image back errored with " << GLErrorName(error); + return 0xFFFFFFFFu; + } + return texels[static_cast(x) * (rgba ? 4u : 1u)]; + } + + void DispatchStore(GLuint program, GLuint texture, GLenum internalFormat) { + glBindImageTexture(static_cast(kImageUnit), texture, 0, GL_FALSE, 0, GL_WRITE_ONLY, + internalFormat); + ASSERT_EQ(FirstGLError(), 0u) << "glBindImageTexture errored"; + glUseProgram(program); + const GLint location = glGetUniformLocation(program, "uni_image"); + ASSERT_GE(location, 0) << "the image uniform was not reflected"; + glUniform1i(location, kImageUnit); + ASSERT_EQ(FirstGLError(), 0u) << "assigning the image unit errored"; + glDispatchCompute(kExtent, 1, 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; + }; + + // The defect itself. Without the bake the ES driver refuses the program outright and the + // texture keeps its seed - which is also exactly what a silently no-op dispatch looks + // like, and why the seed is a value no store writes. + TEST_F(ImageFormatQualifierScenario, AFormatlessWriteonlyImageWrites) { + if (!Ready()) GTEST_SKIP() << "no GL context"; + if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver"; + + const GLuint program = MakeComputeProgram(kStoreSource); + const GLuint texture = MakeTexture(GL_R32UI); + if (program == 0 || texture == 0) return; + + DispatchStore(program, texture, GL_R32UI); + for (int x = 0; x < kExtent; ++x) { + EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast(x) + 100u) + << "texel " << x << " of a format-less writeonly image did not take the store"; + } + } + + // The rebuild key. The SAME program is dispatched twice with a different format bound to + // its unit; a build keyed only on the link (or only on the image UNIT) would reuse the + // r32ui program for the rgba32ui texture, and the second half would come back seeded. + // + // What the SOFTWARE lanes cannot falsify: with the key disabled this case still passes on + // Mesa, because the reused r32ui declaration writes the red channel of an RGBA32UI image + // anyway - a format-class mismatch GL leaves undefined and that driver happens to absorb. + // FirstBindAfterLinkRebuilds below is the case that fails there, because the reused + // program was built with no format at all and never compiled. Both are kept: this one is + // the shape a strict driver is entitled to reject, and it is the shape the device runs. + TEST_F(ImageFormatQualifierScenario, RebindToADifferentFormatRebuilds) { + if (!Ready()) GTEST_SKIP() << "no GL context"; + if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver"; + + const GLuint program = MakeComputeProgram(kStoreSource); + const GLuint first = MakeTexture(GL_R32UI); + const GLuint second = MakeTexture(GL_RGBA32UI); + if (program == 0 || first == 0 || second == 0) return; + + DispatchStore(program, first, GL_R32UI); + for (int x = 0; x < kExtent; ++x) { + ASSERT_EQ(ReadRedTexel(first, GL_R32UI, x), static_cast(x) + 100u) + << "the first format must work before the rebind can be blamed for anything"; + } + + DispatchStore(program, second, GL_RGBA32UI); + for (int x = 0; x < kExtent; ++x) { + EXPECT_EQ(ReadRedTexel(second, GL_RGBA32UI, x), static_cast(x) + 100u) + << "texel " << x << ": the program was not rebuilt for the newly bound format"; + } + + // ...and back, so the rebuild is not a one-way door: returning to a format the + // program was once built against must build for it again, not resurrect a cache row. + const GLuint third = MakeTexture(GL_R32UI); + if (third == 0) return; + DispatchStore(program, third, GL_R32UI); + for (int x = 0; x < kExtent; ++x) { + EXPECT_EQ(ReadRedTexel(third, GL_R32UI, x), static_cast(x) + 100u) + << "texel " << x << ": going back to the first format did not rebuild"; + } + } + + // Nothing is bound to the unit when the program links, so whatever the first build sees + // is not the format the dispatch needs. glBindImageTexture must not itself trigger a + // build - it is an entry point, and building there is the constraint + // glShaderStorageBlockBinding is held to as well - so the rebuild has to happen at the + // next dispatch preparation instead. This case fails either way round: no rebuild, or a + // build attempted from the entry point before the state settles. + TEST_F(ImageFormatQualifierScenario, FirstBindAfterLinkRebuilds) { + if (!Ready()) GTEST_SKIP() << "no GL context"; + if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver"; + + const GLuint program = MakeComputeProgram(kStoreSource); + if (program == 0) return; + + // Use it once with NOTHING bound to the unit, which is what makes the backend build + // against an empty binding. The dispatch writes nowhere and must not error. + glUseProgram(program); + const GLint location = glGetUniformLocation(program, "uni_image"); + ASSERT_GE(location, 0); + glUniform1i(location, kImageUnit); + glDispatchCompute(kExtent, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << "dispatching with an unbound image unit must not error"; + glUseProgram(0); + + const GLuint texture = MakeTexture(GL_R32UI); + if (texture == 0) return; + DispatchStore(program, texture, GL_R32UI); + for (int x = 0; x < kExtent; ++x) { + EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast(x) + 100u) + << "texel " << x << ": the first bind after the link did not reach the shader"; + } + } + + // A DECLARED format is authoritative and the bake must never touch it - including when + // the texture behind the unit has a different (but class-compatible) internal format, + // which GL explicitly allows. If the bake ever overrode a declaration, this is the case + // that would go wrong while every other one stayed green. + TEST_F(ImageFormatQualifierScenario, ADeclaredFormatStillWins) { + if (!Ready()) GTEST_SKIP() << "no GL context"; + if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver"; + + const GLuint program = MakeComputeProgram(R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +layout (r32ui) writeonly uniform uimage2D uni_image; + +void main() +{ + imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(gl_GlobalInvocationID.x + 100u, 0u, 0u, 0u)); +} +)"); + const GLuint texture = MakeTexture(GL_R32UI); + if (program == 0 || texture == 0) return; + + DispatchStore(program, texture, GL_R32UI); + for (int x = 0; x < kExtent; ++x) { + EXPECT_EQ(ReadRedTexel(texture, GL_R32UI, x), static_cast(x) + 100u) + << "texel " << x << ": a declared format stopped working"; + } + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp index 09e124ab..1e0bdb57 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp +++ b/MobileGL/MG_Test/Backend/DirectGLES/EsslShaderPassTest.cpp @@ -16,9 +16,11 @@ #include using namespace MobileGL; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BakeImageFormatQualifiers; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding; +using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats; using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms; namespace { @@ -435,3 +437,116 @@ void main() { tes_gs_coord = tcs_tes_coord[0]; } EXPECT_TRUE(Contains(out, "layout(location = 1) out vec2 tes_gs_coord;")) << out; EXPECT_EQ(CountOf(out, "flat"), 0u) << out; } + +// --- image format qualifier completion --------------------------------------------------------- +// +// GLSL ES requires a format layout qualifier on every image; desktop GLSL lets a writeonly +// declaration omit one. The format is normally written into the SPIR-V before SPIRV-Cross runs +// (BakeImageFormatsPass), but SPIRV-Cross THROWS rather than printing the formats it calls +// desktop-only for ESSL - r8ui among them - so those are completed here, on the emitted text. + +// The KHR-GL4x.packed_depth_stencil.stencil_texturing stencil half: `writeonly uniform uimage2D` +// with GL_R8UI bound to its unit. +TEST(BakeImageFormatQualifiersTest, AFormatlessDeclarationGetsTheBoundFormat) { + const String source = R"(#version 320 es +layout(binding = 1) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(15u)); } +)"; + const String out = BakeImageFormatQualifiers(source, {{"uni_image", "r8ui"}}); + EXPECT_TRUE(Contains(out, "layout(r8ui, binding = 1) uniform writeonly highp uimage2D uni_image;")) << out; +} + +// A declaration with NO layout at all still has to end up with one, or the driver rejects it for +// exactly the reason this pass exists. +TEST(BakeImageFormatQualifiersTest, ADeclarationWithNoLayoutGetsOne) { + const String source = R"(#version 320 es +uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + const String out = BakeImageFormatQualifiers(source, {{"uni_image", "r16i"}}); + EXPECT_TRUE(Contains(out, "layout(r16i) uniform writeonly highp uimage2D uni_image;")) << out; +} + +// A DECLARED format is authoritative and must survive, whatever the map says - the frontend never +// puts a declared image in the map, and the pass must not depend on that being true. +TEST(BakeImageFormatQualifiersTest, ADeclaredFormatIsNeverOverwritten) { + const String source = R"(#version 320 es +layout(binding = 1, rgba8ui) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + const String out = BakeImageFormatQualifiers(source, {{"uni_image", "r8ui"}}); + EXPECT_EQ(out, source) << out; +} + +// Only the named uniform. A second image in the same shader - format-less because the pass +// declined it, or because its unit holds nothing - must be left exactly as it is. +TEST(BakeImageFormatQualifiersTest, OnlyTheNamedUniformIsTouched) { + const String source = R"(#version 320 es +layout(binding = 0) uniform writeonly highp uimage2D named; +layout(binding = 1) uniform writeonly highp uimage2D other; +void main() { imageStore(named, ivec2(0), uvec4(1u)); imageStore(other, ivec2(0), uvec4(2u)); } +)"; + const String out = BakeImageFormatQualifiers(source, {{"named", "r8ui"}}); + EXPECT_TRUE(Contains(out, "layout(r8ui, binding = 0) uniform writeonly highp uimage2D named;")) << out; + EXPECT_TRUE(Contains(out, "layout(binding = 1) uniform writeonly highp uimage2D other;")) << out; +} + +// The format the pass writes has to survive the two passes that run after it, or nothing was +// gained: the read+write split copies declarations, and the binding strip edits layout qualifiers. +TEST(BakeImageFormatQualifiersTest, TheWrittenFormatSurvivesTheLaterImagePasses) { + const String source = R"(#version 320 es +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 = RemoveLayoutBinding(out); + EXPECT_TRUE(Contains(out, "r8ui")) << out; + EXPECT_TRUE(Contains(out, "binding = 3")) << out; +} + +TEST(BakeImageFormatQualifiersTest, AnEmptyMapOrAnImagelessShaderIsANoOp) { + const String withImage = R"(#version 320 es +layout(binding = 1) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + EXPECT_EQ(BakeImageFormatQualifiers(withImage, {}), withImage); + + const String withoutImage = R"(#version 320 es +layout(location = 0) out highp vec4 mg_FragColor; +void main() { mg_FragColor = vec4(1.0); } +)"; + EXPECT_EQ(BakeImageFormatQualifiers(withoutImage, {{"uni_image", "r8ui"}}), withoutImage); +} + +// --- GL_NV_image_formats directive -------------------------------------------------------------- + +TEST(RequestExtendedImageFormatsTest, TheDirectiveGoesRightAfterTheVersionLine) { + const String source = R"(#version 320 es +layout(r8ui, binding = 1) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + const String out = RequestExtendedImageFormats(source, true); + EXPECT_TRUE(Contains(out, "#version 320 es\n#extension GL_NV_image_formats : require\n")) << out; +} + +// Never speculatively: `#extension` naming an extension the driver does not advertise is itself a +// compile error, so the caller's "not needed" answer has to be honoured exactly. +TEST(RequestExtendedImageFormatsTest, NotNeededMeansNotEmitted) { + const String source = R"(#version 320 es +layout(rgba8ui, binding = 1) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + EXPECT_EQ(RequestExtendedImageFormats(source, false), source); +} + +TEST(RequestExtendedImageFormatsTest, AnAlreadyPresentDirectiveIsNotDuplicated) { + const String source = R"(#version 320 es +#extension GL_NV_image_formats : require +layout(r8ui, binding = 1) uniform writeonly highp uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)"; + const String out = RequestExtendedImageFormats(source, true); + EXPECT_EQ(out, source); + EXPECT_EQ(CountOf(out, "GL_NV_image_formats"), 1u) << out; +} diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 0a727f75..8161fe8f 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -3689,3 +3689,316 @@ void main() { ssb.sum = uint(imageSize(i0).x) + imageLoad(i0, ivec2(0, 0)).r; } EXPECT_EQ(Count1DArrayStorageImageTypes(lowered), 1u) << "declining means the 1D-array 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 +// requires one of every image, and Adreno says so as "all images have to define layout format", +// losing the whole program. The only correct qualifier to substitute is the format the +// application passed to glBindImageTexture for that unit, so the transpile bakes it in. + +namespace { + Uint CountSpirvOpcode(const String& disassembly, const String& opcode) { + Uint count = 0; + SizeT offset = 0; + const String needle = opcode + " "; + while ((offset = disassembly.find(needle, offset)) != String::npos) { + count += 1; + offset += needle.size(); + } + return count; + } + + constexpr Uint kGlR32ui = 0x8236; + constexpr Uint kGlRgba32ui = 0x8D70; + constexpr Uint kGlR8ui = 0x8232; + constexpr Uint kGlR32f = 0x822E; +} // namespace + +// The KHR-GL4x.packed_depth_stencil.stencil_texturing compute shader, reduced: one format-less +// writeonly image, and a bind of a concrete format to the unit it addresses. (The DEPTH half of +// that case binds GL_R32F; the stencil half's GL_R8UI is one SPIRV-Cross will not print and takes +// the text route instead - see the test below.) +TEST_F(ProgramUtilTest, BakeImageFormatsGivesAFormatlessImageTheFormatBoundToItsUnit) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(15u, 0u, 0u, 0u)); } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresFormatlessStorageImage(spirv)) + << "the fixture must reproduce the defect before the fix is asked to remove it:\n" + << DisassembleSpirv(spirv); + // Precondition: SPIRV-Cross prints no format for it, which is the ESSL the driver refuses. + EXPECT_EQ(DecompileToEssl(spirv).find("r32ui"), String::npos); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR32ui}}, baked)); + ASSERT_FALSE(baked.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(baked)) << DisassembleSpirv(baked); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the baked module must stay validator-clean:\n" + << DisassembleSpirv(baked); + + const String essl = DecompileToEssl(baked); + ASSERT_FALSE(essl.empty()); + EXPECT_NE(essl.find("r32ui"), String::npos) + << "the bound format must reach the declaration as a layout qualifier:\n" << essl; + EXPECT_NE(essl.find("writeonly"), String::npos) + << "the access qualifier the declaration already had must survive:\n" << essl; +} + +// 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) { + 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"; + ASSERT_TRUE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlR32ui)); + EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(kGlR8ui), "r8ui"); + EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(0x8051 /*GL_RGB8*/), ""); + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D uni_image; +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()); +} + +// A DECLARED format is authoritative: GL requires the qualifier, the bind format and the +// texture's internal format to be in the same class, but the qualifier is what the shader is +// specified to read the memory as, and a bake that overrode it would change what the shader does. +TEST_F(ProgramUtilTest, BakeImageFormatsNeverOverridesADeclaredFormat) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +layout (binding = 0, rgba32ui) writeonly uniform uimage2D uni_image; +void main() { imageStore(uni_image, ivec2(0), uvec4(1u)); } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(spirv)); + + Vector baked; + // Even asked to, with a format of the right component class. + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR32ui}}, baked)); + EXPECT_EQ(baked, spirv) << "a module with nothing format-less must pass through byte for byte"; + EXPECT_NE(DecompileToEssl(baked).find("rgba32ui"), String::npos); +} + +// Review finding. Every use has to be one the retype can carry end to end, and the decision has +// to be made BEFORE anything is mutated - a half-retyped module is not something a later decline +// could undo. An image handed to a FUNCTION is the shape that reaches SPIRV-Cross intact (nothing +// in the ESSL chain inlines), and its OpFunctionCall is a use this pass does not follow. +TEST_F(ProgramUtilTest, BakeImageFormatsDeclinesAnImagePassedToAFunction) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D uni_image; +void writeIt(writeonly uimage2D img) { imageStore(img, ivec2(0), uvec4(1u)); } +void main() { writeIt(uni_image); } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresFormatlessStorageImage(spirv)); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR32ui}}, baked)); + EXPECT_EQ(baked, spirv) << "a shape the retype cannot follow must leave the module untouched, " + "not partly rewritten:\n" + << DisassembleSpirv(baked); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore); +} + +// spirv-val requires the Image Format's component class to agree with the OpTypeImage's Sampled +// Type. Binding a uint format to a float image is an application error GL leaves undefined; +// baking it would turn that into an INVALID module, which is strictly worse than the compile +// error the shader already has, so the image is left format-less. +TEST_F(ProgramUtilTest, BakeImageFormatsDeclinesAFormatOfTheWrongComponentClass) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform image2D uni_image; +void main() { imageStore(uni_image, ivec2(0), vec4(1.0)); } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR32ui}}, baked)); + EXPECT_EQ(baked, spirv) << "a declined module must be handed back untouched, not partly rewritten"; + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore); + + // ...and the same image with a float bind format is baked, so the decline above is about the + // class and not about the pass refusing float images. + Vector bakedFloat; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR32f}}, bakedFloat)); + EXPECT_NE(DecompileToEssl(bakedFloat).find("r32f"), String::npos) << DisassembleSpirv(bakedFloat); +} + +// Two format-less images of the same type share ONE OpTypeImage. Giving them different formats +// therefore cannot be an in-place edit of that type - each needs its own declaration, and the +// variable, the loads and (for arrays) the access chains all have to follow. +TEST_F(ProgramUtilTest, BakeImageFormatsSplitsATypeTwoImagesShareWhenTheirFormatsDiffer) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D imgA; +writeonly uniform uimage2D imgB; +void main() { + imageStore(imgA, ivec2(0), uvec4(1u)); + imageStore(imgB, ivec2(0), uvec4(2u)); +} +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_EQ(CountSpirvOpcode(DisassembleSpirv(spirv), "OpTypeImage"), 1u) + << "the fixture must have the two images sharing one type:\n" << DisassembleSpirv(spirv); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl( + spirv, {{"imgA", kGlR32ui}, {"imgB", kGlRgba32ui}}, baked)); + ASSERT_FALSE(baked.empty()); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "splitting the shared type must not leave a dangling or duplicate declaration:\n" + << DisassembleSpirv(baked); + EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(baked)); + + const String essl = DecompileToEssl(baked); + ASSERT_FALSE(essl.empty()); + EXPECT_NE(essl.find("r32ui"), String::npos) << essl; + EXPECT_NE(essl.find("rgba32ui"), String::npos) << essl; +} + +// The mirror of the split: when the module ALREADY declares the type the bake wants, the two must +// be JOINED, not duplicated. SPIR-V forbids two identical non-aggregate type declarations, and +// that is exactly the defect an earlier image pass shipped and a reviewer caught. +TEST_F(ProgramUtilTest, BakeImageFormatsJoinsATypeTheModuleAlreadyDeclares) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D formatless; +layout (binding = 1, r32ui) writeonly uniform uimage2D declared; +void main() { + imageStore(formatless, ivec2(0), uvec4(1u)); + imageStore(declared, ivec2(0), uvec4(2u)); +} +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_EQ(CountSpirvOpcode(DisassembleSpirv(spirv), "OpTypeImage"), 2u) + << "the fixture needs one Unknown-format and one r32ui image type:\n" << DisassembleSpirv(spirv); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"formatless", kGlR32ui}}, baked)); + ASSERT_FALSE(baked.empty()); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the baked image collided with the module's own r32ui image and left a duplicate type:\n" + << DisassembleSpirv(baked); + EXPECT_EQ(CountSpirvOpcode(DisassembleSpirv(baked), "OpTypeImage"), 1u) + << "the two identical image types must be the same declaration:\n" << DisassembleSpirv(baked); +} + +// An ARRAY of format-less images: the variable's type is a pointer to an array, every use goes +// through an OpAccessChain, and all three levels have to be rebuilt for the load to still type-check. +TEST_F(ProgramUtilTest, BakeImageFormatsRetypesAnArrayOfFormatlessImagesThroughItsAccessChains) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +layout (local_size_x = 1) in; +writeonly uniform uimage2D imgs[2]; +void main() { + for (int i = 0; i < 2; ++i) imageStore(imgs[i], ivec2(0), uvec4(uint(i))); +} +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresFormatlessStorageImage(spirv)); + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"imgs", kGlR32ui}}, baked)); + ASSERT_FALSE(baked.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(baked)) << DisassembleSpirv(baked); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the array and pointer types above the image must have been rebuilt too:\n" + << DisassembleSpirv(baked); + EXPECT_NE(DecompileToEssl(baked).find("r32ui"), String::npos); +} + +// A SAMPLED image's format operand is Unknown in every GLSL dialect and has no qualifier to bake; +// only storage images (Sampled == 2) are in scope. +TEST_F(ProgramUtilTest, BakeImageFormatsLeavesSampledImagesAlone) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 430 core +uniform usampler2D uni_sampler; +out uvec4 fragColor; +in vec2 vUv; +void main() { fragColor = texture(uni_sampler, vUv); } +)", + GL_FRAGMENT_SHADER); + ASSERT_FALSE(spirv.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(spirv)) + << "a sampled image must not read as a format-less STORAGE image:\n" << DisassembleSpirv(spirv); + + Vector baked; + ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_sampler", kGlR32ui}}, baked)); + EXPECT_EQ(baked, spirv) << "a sampled image must pass through byte for byte"; +} + +// The core/extended split the emitted ESSL depends on: GLSL ES has thirteen image formats, and a +// bind format outside them only compiles with GL_NV_image_formats - which the backend must not +// request on a driver that does not advertise it. +TEST_F(ProgramUtilTest, EsslCoreImageFormatSetIsTheThirteenTheSpecLists) { + using namespace MG_Util::ShaderTranspiler; + + EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(kGlR32ui)); + EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(kGlRgba32ui)); + EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(kGlR32f)); + EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0x8058 /*GL_RGBA8*/)); + // The stencil half of KHR-GL4x.packed_depth_stencil.stencil_texturing binds this one, and it + // is NOT core - the whole reason the directive machinery exists. + EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(kGlR8ui)); + EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0x822D /*GL_R16F*/)); + // Not an image format at all. + EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0x8051 /*GL_RGB8*/)); + EXPECT_FALSE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(0 /*GL_NONE*/)); +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 81ef0908..212af1ed 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -912,6 +912,9 @@ namespace MobileGL::MG_Util::BackendLoader { if (std::strcmp(extension, "GL_NV_shader_noperspective_interpolation") == 0) { caps.SupportsNoperspectiveInterpolation = true; } + if (std::strcmp(extension, "GL_NV_image_formats") == 0) { + caps.SupportsExtendedImageFormats = true; + } if (std::strcmp(extension, "GL_OES_shader_multisample_interpolation") == 0) { caps.SupportsShaderMultisampleInterpolation = true; } diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index 1cf6a1b7..e69d0798 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1138,6 +1138,14 @@ namespace MobileGL { // SPIRV-Cross's `#extension ... : require` would fail to compile and MobileGL falls back // to stripping the NoPerspective decoration (smooth interpolation) via StripNoPerspectivePass. Bool SupportsNoperspectiveInterpolation = false; + // GL_NV_image_formats is present: the driver accepts the image format qualifiers GL + // has and GLSL ES core does not (the one- and two-channel formats, the 16-bit and + // snorm ones - r8ui, rg16f, rgba16 and the rest of GL table 8.26). GLSL ES core has + // only thirteen, so without this an image whose bound format is outside that set has + // no legal spelling in the generated ESSL at all, and the directive must not be + // emitted either - `#extension` on a name the driver does not advertise is itself a + // compile error. + Bool SupportsExtendedImageFormats = false; // GLES 3.2 core or GL_OES_shader_multisample_interpolation exposes // interpolateAtOffset and the three fragment-offset limit queries. Bool SupportsShaderMultisampleInterpolation = false; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 27504985..7a69df68 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -27,6 +27,7 @@ #include "SpirvPasses/ZeroBaseVertexPass.h" #include "SpirvPasses/NormalizeRectCoordinatesPass.h" #include "SpirvPasses/Lower1DArrayImagesPass.h" +#include "SpirvPasses/BakeImageFormatsPass.h" #include "SpirvPasses/PrivateToEntryLocalPass.h" #include "SpirvPasses/StripUniformLocationsPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" @@ -686,6 +687,35 @@ namespace MobileGL { outputBinary); } + bool ShaderCompiler::BakeImageFormatsForEssl(const Vector& inputBinary, + const UnorderedMap& glFormatByName, + Vector& outputBinary) { + using namespace spvtools; + if (glFormatByName.empty()) return false; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(BakeImageFormatsPass::CreateBakeImageFormatsPass(glFormatByName)); + + return RunOptimizerChecked("BakeImageFormatsForEssl", optimizer, inputBinary, outputBinary); + } + + bool ShaderCompiler::DeclaresFormatlessStorageImage(const Vector& binary) { + return BakeImageFormatsPass::DeclaresFormatlessStorageImage(binary); + } + + bool ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(Uint glInternalFormat) { + return BakeImageFormatsPass::IsCoreEsslImageFormat( + BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(glInternalFormat)); + } + + String ShaderCompiler::EsslImageFormatSpelling(Uint glInternalFormat) { + return BakeImageFormatsPass::EsslSpellingOfGLInternalFormat(glInternalFormat); + } + + bool ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(Uint glInternalFormat) { + return BakeImageFormatsPass::IsSpirvCrossEsslPrintableFormat( + BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(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 47ce045e..3469823a 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -97,6 +97,32 @@ namespace MobileGL { // but a handful. See Lower1DArrayImagesPass for what it declines and why. static bool Lower1DArrayImagesForEssl(const Vector& inputBinary, Vector& outputBinary); + // 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` + // maps uniform name to the glBindImageTexture format of the unit it addresses. + // DirectGLES transpile path only - Vulkan takes an Unknown-format storage image + // natively. See BakeImageFormatsPass for what it declines and why. + static bool BakeImageFormatsForEssl(const Vector& inputBinary, + const UnorderedMap& glFormatByName, + Vector& outputBinary); + // Whether the module declares a storage image with no format qualifier at all, + // i.e. whether BakeImageFormatsForEssl could change anything. One module parse, + // so the ~every shader that declares none pays no optimizer run. + static bool DeclaresFormatlessStorageImage(const Vector& binary); + // Whether the GL internal format's image-format spelling is one GLSL ES has in + // core. False both for a format ES only reaches through GL_NV_image_formats and + // for one with no image-format spelling at all, so a caller that has to decide + // whether to emit the extension directive can ask this one question. + static bool GLInternalFormatIsCoreEsslImageFormat(Uint glInternalFormat); + // The ESSL layout-qualifier spelling of a GL internal format ("r8ui", "rgba32f"), + // empty when the format has no image-format spelling at all. + static String EsslImageFormatSpelling(Uint glInternalFormat); + // Whether SPIRV-Cross will print that format when it targets ESSL. It throws on + // the ones it calls desktop-only - taking the whole stage with it - so a caller + // must not ask BakeImageFormatsForEssl for those, and completes them in the + // emitted text instead. + static bool SpirvCrossCanPrintEsslImageFormat(Uint glInternalFormat); static bool RebaseInstanceIndexForVulkan(const Vector& inputBinary, Vector& outputBinary); // Builds the non-indexed-draw variant of a vertex shader: every gl_BaseVertex diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp new file mode 100644 index 00000000..aa5bfc51 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp @@ -0,0 +1,706 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.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 "BakeImageFormatsPass.h" + +#include "spirv.hpp" +#include "source/opt/build_module.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/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; + + // OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS, + // 5 Sampled, 6 Format, (7 Access Qualifier - Kernel only, never present here). + 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 has a format qualifier in any GLSL dialect. + constexpr uint32_t kSampledStorageImage = 2; + + // OpTypePointer in-operands: 0 storage class, 1 pointee. + constexpr uint32_t kPointerStorageClassOperand = 0; + constexpr uint32_t kPointerPointeeOperand = 1; + // OpTypeArray in-operands: 0 element type, 1 length. + constexpr uint32_t kArrayElementOperand = 0; + + bool IsFormatlessStorageImageType(const Instruction* type) { + return type != nullptr && type->opcode() == spv::Op::OpTypeImage && + type->GetSingleWordInOperand(kImageSampledOperand) == kSampledStorageImage && + static_cast(type->GetSingleWordInOperand(kImageFormatOperand)) == + spv::ImageFormat::Unknown; + } + + // The three component classes a format layout qualifier can have. spirv-val + // requires the Image Format's class to agree with the OpTypeImage's Sampled + // Type, so a bake that disagrees would produce an invalid module rather than a + // merely wrong one. + enum class ComponentClass { Float, SignedInt, UnsignedInt, None }; + + ComponentClass ClassOfImageFormat(spv::ImageFormat format) { + switch (format) { + case spv::ImageFormat::Rgba32f: + case spv::ImageFormat::Rgba16f: + case spv::ImageFormat::R32f: + case spv::ImageFormat::Rgba8: + case spv::ImageFormat::Rgba8Snorm: + case spv::ImageFormat::Rg32f: + case spv::ImageFormat::Rg16f: + case spv::ImageFormat::R11fG11fB10f: + case spv::ImageFormat::R16f: + case spv::ImageFormat::Rgba16: + case spv::ImageFormat::Rgb10A2: + case spv::ImageFormat::Rg16: + case spv::ImageFormat::Rg8: + case spv::ImageFormat::R16: + case spv::ImageFormat::R8: + case spv::ImageFormat::Rgba16Snorm: + case spv::ImageFormat::Rg16Snorm: + case spv::ImageFormat::Rg8Snorm: + case spv::ImageFormat::R16Snorm: + case spv::ImageFormat::R8Snorm: + return ComponentClass::Float; + case spv::ImageFormat::Rgba32i: + case spv::ImageFormat::Rgba16i: + case spv::ImageFormat::Rgba8i: + case spv::ImageFormat::R32i: + case spv::ImageFormat::Rg32i: + case spv::ImageFormat::Rg16i: + case spv::ImageFormat::Rg8i: + case spv::ImageFormat::R16i: + case spv::ImageFormat::R8i: + return ComponentClass::SignedInt; + case spv::ImageFormat::Rgba32ui: + case spv::ImageFormat::Rgba16ui: + case spv::ImageFormat::Rgba8ui: + case spv::ImageFormat::R32ui: + case spv::ImageFormat::Rgb10a2ui: + case spv::ImageFormat::Rg32ui: + case spv::ImageFormat::Rg16ui: + case spv::ImageFormat::Rg8ui: + case spv::ImageFormat::R16ui: + case spv::ImageFormat::R8ui: + return ComponentClass::UnsignedInt; + default: + return ComponentClass::None; + } + } + + ComponentClass ClassOfSampledType(IRContext* context, uint32_t sampledTypeId) { + const Instruction* sampledType = context->get_def_use_mgr()->GetDef(sampledTypeId); + if (sampledType == nullptr) return ComponentClass::None; + if (sampledType->opcode() == spv::Op::OpTypeFloat) return ComponentClass::Float; + if (sampledType->opcode() == spv::Op::OpTypeInt) { + // OpTypeInt in-operands: 0 width, 1 signedness. + return sampledType->GetSingleWordInOperand(1) != 0 ? ComponentClass::SignedInt + : ComponentClass::UnsignedInt; + } + return ComponentClass::None; + } + + // The image type at the end of a UniformConstant variable's type chain, plus the + // links along the way. Anything that is not `pointer -> [array ->] image` comes + // back with a null image and is left alone. + struct ImageTypeChain { + Instruction* pointerType = nullptr; // the variable's own result type + Instruction* arrayType = nullptr; // null when the variable is a single image + Instruction* imageType = nullptr; + }; + + ImageTypeChain ResolveImageTypeChain(IRContext* context, const Instruction& variable) { + ImageTypeChain chain; + auto* defUseMgr = context->get_def_use_mgr(); + Instruction* pointerType = defUseMgr->GetDef(variable.type_id()); + if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) { + return chain; + } + chain.pointerType = pointerType; + Instruction* pointee = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(kPointerPointeeOperand)); + if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) { + chain.arrayType = pointee; + pointee = defUseMgr->GetDef(pointee->GetSingleWordInOperand(kArrayElementOperand)); + } + if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeImage) { + chain.imageType = pointee; + } + return chain; + } + + // Instructions that consume an image VALUE (the result of an OpLoad) and need no + // result-type change of their own: the texel type they yield is independent of + // the format operand. + bool ConsumesImageValueWithoutRetyping(spv::Op opcode) { + switch (opcode) { + case spv::Op::OpImageWrite: + case spv::Op::OpImageRead: + case spv::Op::OpImageSparseRead: + case spv::Op::OpImageQuerySize: + case spv::Op::OpImageQuerySizeLod: + case spv::Op::OpImageQuerySamples: + case spv::Op::OpImageQueryLevels: + case spv::Op::OpImageQueryFormat: + case spv::Op::OpImageQueryOrder: + return true; + default: + return false; + } + } + + // Debug/annotation instructions name an id without depending on its type. + bool IsTypeAgnosticReference(spv::Op opcode) { + switch (opcode) { + case spv::Op::OpName: + case spv::Op::OpMemberName: + case spv::Op::OpDecorate: + case spv::Op::OpDecorateId: + case spv::Op::OpDecorateString: + case spv::Op::OpMemberDecorate: + case spv::Op::OpEntryPoint: + return true; + default: + return false; + } + } + } // namespace + + Uint32 BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(Uint glInternalFormat) { + switch (glInternalFormat) { + // The GL 4.2 image format table (core spec table 8.26), in its order. Written as + // literals rather than through the GL headers because this lives in MG_Util, + // which the GL frontend's enums do not reach. + case 0x8814: /*GL_RGBA32F*/ return static_cast(spv::ImageFormat::Rgba32f); + case 0x881A: /*GL_RGBA16F*/ return static_cast(spv::ImageFormat::Rgba16f); + case 0x8230: /*GL_RG32F*/ return static_cast(spv::ImageFormat::Rg32f); + case 0x822F: /*GL_RG16F*/ return static_cast(spv::ImageFormat::Rg16f); + case 0x8C3A: /*GL_R11F_G11F_B10F*/ return static_cast(spv::ImageFormat::R11fG11fB10f); + case 0x822E: /*GL_R32F*/ return static_cast(spv::ImageFormat::R32f); + case 0x822D: /*GL_R16F*/ return static_cast(spv::ImageFormat::R16f); + case 0x8D70: /*GL_RGBA32UI*/ return static_cast(spv::ImageFormat::Rgba32ui); + case 0x8D76: /*GL_RGBA16UI*/ return static_cast(spv::ImageFormat::Rgba16ui); + case 0x8D7C: /*GL_RGBA8UI*/ return static_cast(spv::ImageFormat::Rgba8ui); + case 0x906F: /*GL_RGB10_A2UI*/ return static_cast(spv::ImageFormat::Rgb10a2ui); + case 0x823C: /*GL_RG32UI*/ return static_cast(spv::ImageFormat::Rg32ui); + case 0x823A: /*GL_RG16UI*/ return static_cast(spv::ImageFormat::Rg16ui); + case 0x8238: /*GL_RG8UI*/ return static_cast(spv::ImageFormat::Rg8ui); + case 0x8236: /*GL_R32UI*/ return static_cast(spv::ImageFormat::R32ui); + case 0x8234: /*GL_R16UI*/ return static_cast(spv::ImageFormat::R16ui); + case 0x8232: /*GL_R8UI*/ return static_cast(spv::ImageFormat::R8ui); + case 0x8D82: /*GL_RGBA32I*/ return static_cast(spv::ImageFormat::Rgba32i); + case 0x8D88: /*GL_RGBA16I*/ return static_cast(spv::ImageFormat::Rgba16i); + case 0x8D8E: /*GL_RGBA8I*/ return static_cast(spv::ImageFormat::Rgba8i); + case 0x823B: /*GL_RG32I*/ return static_cast(spv::ImageFormat::Rg32i); + case 0x8239: /*GL_RG16I*/ return static_cast(spv::ImageFormat::Rg16i); + case 0x8237: /*GL_RG8I*/ return static_cast(spv::ImageFormat::Rg8i); + case 0x8235: /*GL_R32I*/ return static_cast(spv::ImageFormat::R32i); + case 0x8233: /*GL_R16I*/ return static_cast(spv::ImageFormat::R16i); + case 0x8231: /*GL_R8I*/ return static_cast(spv::ImageFormat::R8i); + case 0x8058: /*GL_RGBA8*/ return static_cast(spv::ImageFormat::Rgba8); + case 0x805B: /*GL_RGBA16*/ return static_cast(spv::ImageFormat::Rgba16); + case 0x8059: /*GL_RGB10_A2*/ return static_cast(spv::ImageFormat::Rgb10A2); + case 0x822B: /*GL_RG8*/ return static_cast(spv::ImageFormat::Rg8); + case 0x822C: /*GL_RG16*/ return static_cast(spv::ImageFormat::Rg16); + case 0x8229: /*GL_R8*/ return static_cast(spv::ImageFormat::R8); + case 0x822A: /*GL_R16*/ return static_cast(spv::ImageFormat::R16); + case 0x8F97: /*GL_RGBA8_SNORM*/ return static_cast(spv::ImageFormat::Rgba8Snorm); + case 0x8F9B: /*GL_RGBA16_SNORM*/ return static_cast(spv::ImageFormat::Rgba16Snorm); + case 0x8F95: /*GL_RG8_SNORM*/ return static_cast(spv::ImageFormat::Rg8Snorm); + case 0x8F99: /*GL_RG16_SNORM*/ return static_cast(spv::ImageFormat::Rg16Snorm); + case 0x8F94: /*GL_R8_SNORM*/ return static_cast(spv::ImageFormat::R8Snorm); + case 0x8F98: /*GL_R16_SNORM*/ return static_cast(spv::ImageFormat::R16Snorm); + default: + return static_cast(spv::ImageFormat::Unknown); + } + } + + bool BakeImageFormatsPass::IsCoreEsslImageFormat(Uint32 spirvImageFormat) { + // GLSL ES 3.1 / 3.2, table "Image Formats". Everything else in the GL table + // exists on ES only through GL_NV_image_formats. + switch (static_cast(spirvImageFormat)) { + case spv::ImageFormat::Rgba32f: + case spv::ImageFormat::Rgba16f: + case spv::ImageFormat::R32f: + case spv::ImageFormat::Rgba8: + case spv::ImageFormat::Rgba8Snorm: + case spv::ImageFormat::Rgba32i: + case spv::ImageFormat::Rgba16i: + case spv::ImageFormat::Rgba8i: + case spv::ImageFormat::R32i: + case spv::ImageFormat::Rgba32ui: + case spv::ImageFormat::Rgba16ui: + case spv::ImageFormat::Rgba8ui: + case spv::ImageFormat::R32ui: + return true; + default: + return false; + } + } + + bool BakeImageFormatsPass::IsSpirvCrossEsslPrintableFormat(Uint32 spirvImageFormat) { + // Mirrors SPIRV-Cross's Compiler::is_desktop_only_format (spirv_cross.cpp), which + // CompilerGLSL::format_to_glsl consults before printing: for ESSL output it throws + // on these instead of emitting a token, and the throw takes the whole stage with + // it. NOT the same set as "outside GLSL ES core" - SPIRV-Cross is happy to print + // rg32f, rg16f and the rest of the two-channel 16/32-bit formats for ES, which + // core ES does not have either. Kept as its own list for that reason: the + // question here is what the emitter will do, not what the language allows. + switch (static_cast(spirvImageFormat)) { + case spv::ImageFormat::R11fG11fB10f: + case spv::ImageFormat::R16f: + case spv::ImageFormat::Rgb10A2: + case spv::ImageFormat::R8: + case spv::ImageFormat::Rg8: + case spv::ImageFormat::R16: + case spv::ImageFormat::Rg16: + case spv::ImageFormat::Rgba16: + case spv::ImageFormat::R16Snorm: + case spv::ImageFormat::Rg16Snorm: + case spv::ImageFormat::Rgba16Snorm: + case spv::ImageFormat::R8Snorm: + case spv::ImageFormat::Rg8Snorm: + case spv::ImageFormat::R8ui: + case spv::ImageFormat::Rg8ui: + case spv::ImageFormat::R16ui: + case spv::ImageFormat::Rgb10a2ui: + case spv::ImageFormat::R8i: + case spv::ImageFormat::Rg8i: + case spv::ImageFormat::R16i: + return false; + case spv::ImageFormat::Unknown: + return false; + default: + return true; + } + } + + String BakeImageFormatsPass::EsslSpellingOfGLInternalFormat(Uint glInternalFormat) { + switch (static_cast(SpirvImageFormatFromGLInternalFormat(glInternalFormat))) { + case spv::ImageFormat::Rgba32f: return "rgba32f"; + case spv::ImageFormat::Rgba16f: return "rgba16f"; + case spv::ImageFormat::R32f: return "r32f"; + case spv::ImageFormat::Rgba8: return "rgba8"; + case spv::ImageFormat::Rgba8Snorm: return "rgba8_snorm"; + case spv::ImageFormat::Rg32f: return "rg32f"; + case spv::ImageFormat::Rg16f: return "rg16f"; + case spv::ImageFormat::R11fG11fB10f: return "r11f_g11f_b10f"; + case spv::ImageFormat::R16f: return "r16f"; + case spv::ImageFormat::Rgba16: return "rgba16"; + case spv::ImageFormat::Rgb10A2: return "rgb10_a2"; + case spv::ImageFormat::Rg16: return "rg16"; + case spv::ImageFormat::Rg8: return "rg8"; + case spv::ImageFormat::R16: return "r16"; + case spv::ImageFormat::R8: return "r8"; + case spv::ImageFormat::Rgba16Snorm: return "rgba16_snorm"; + case spv::ImageFormat::Rg16Snorm: return "rg16_snorm"; + case spv::ImageFormat::Rg8Snorm: return "rg8_snorm"; + case spv::ImageFormat::R16Snorm: return "r16_snorm"; + case spv::ImageFormat::R8Snorm: return "r8_snorm"; + case spv::ImageFormat::Rgba32i: return "rgba32i"; + case spv::ImageFormat::Rgba16i: return "rgba16i"; + case spv::ImageFormat::Rgba8i: return "rgba8i"; + case spv::ImageFormat::R32i: return "r32i"; + case spv::ImageFormat::Rg32i: return "rg32i"; + case spv::ImageFormat::Rg16i: return "rg16i"; + case spv::ImageFormat::Rg8i: return "rg8i"; + case spv::ImageFormat::R16i: return "r16i"; + case spv::ImageFormat::R8i: return "r8i"; + case spv::ImageFormat::Rgba32ui: return "rgba32ui"; + case spv::ImageFormat::Rgba16ui: return "rgba16ui"; + case spv::ImageFormat::Rgba8ui: return "rgba8ui"; + case spv::ImageFormat::R32ui: return "r32ui"; + case spv::ImageFormat::Rgb10a2ui: return "rgb10_a2ui"; + case spv::ImageFormat::Rg32ui: return "rg32ui"; + case spv::ImageFormat::Rg16ui: return "rg16ui"; + case spv::ImageFormat::Rg8ui: return "rg8ui"; + case spv::ImageFormat::R16ui: return "r16ui"; + case spv::ImageFormat::R8ui: return "r8ui"; + default: + return {}; + } + } + + bool BakeImageFormatsPass::DeclaresFormatlessStorageImage(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 (IsFormatlessStorageImageType(&type)) { + return true; + } + } + return false; + } + + spvtools::opt::Pass::Status BakeImageFormatsPass::Process() { + auto* irContext = context(); + auto* defUseMgr = irContext->get_def_use_mgr(); + + // Cheap gate first: no format-less storage image type, nothing this pass can do, + // and the module is handed back byte-identical. + bool hasFormatlessType = false; + for (const Instruction& type : irContext->types_values()) { + if (IsFormatlessStorageImageType(&type)) { + hasFormatlessType = true; + break; + } + } + if (!hasFormatlessType || m_glFormatByName.empty()) { + return Status::SuccessWithoutChange; + } + + // Variable name -> OpName target, built once. OpName is how the frontend's + // reflection and this module agree on which uniform is which; SPIRV-Cross + // preserves it, which is also why the emitted ESSL can be matched by name later. + std::map nameById; + for (const Instruction& debugInst : irContext->debugs2()) { + if (debugInst.opcode() == spv::Op::OpName) { + nameById.emplace(debugInst.GetSingleWordInOperand(0), &debugInst); + } + } + + struct Candidate { + Instruction* variable = nullptr; + ImageTypeChain chain; + spv::ImageFormat format = spv::ImageFormat::Unknown; + // The access chains and loads that reach the image through this variable, + // collected while validating so the mutation half never has to re-walk. + std::vector accessChains; + std::vector loads; + }; + std::vector candidates; + + for (Instruction& global : irContext->types_values()) { + if (global.opcode() != spv::Op::OpVariable) continue; + if (static_cast(global.GetSingleWordInOperand(0)) != + spv::StorageClass::UniformConstant) { + continue; + } + // An initializer would be a second operand, and the variable is moved behind + // its new type below - which would put it in front of that initializer. + // GLSL never gives a UniformConstant image one; refuse rather than reason. + if (global.NumInOperands() > 1) continue; + const ImageTypeChain chain = ResolveImageTypeChain(irContext, global); + if (!IsFormatlessStorageImageType(chain.imageType)) continue; + + const auto nameIt = nameById.find(global.result_id()); + if (nameIt == nameById.end()) continue; + const String uniformName = nameIt->second->GetInOperand(1).AsString(); + const auto formatIt = m_glFormatByName.find(uniformName); + if (formatIt == m_glFormatByName.end()) continue; + + const auto format = + static_cast(SpirvImageFormatFromGLInternalFormat(formatIt->second)); + if (format == spv::ImageFormat::Unknown) continue; + // SPIRV-Cross THROWS rather than prints for the formats it calls + // desktop-only when targeting ESSL, and a throw loses the whole stage - so + // baking one of those would trade a missing qualifier for a missing shader. + // 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; + // 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 + // is declined and the image stays format-less. + if (ClassOfImageFormat(format) != + ClassOfSampledType(irContext, + chain.imageType->GetSingleWordInOperand(kImageSampledTypeOperand))) { + continue; + } + + Candidate candidate; + candidate.variable = &global; + candidate.chain = chain; + candidate.format = format; + + // Validate every use BEFORE anything is mutated: a shape this pass cannot + // retype end to end has to leave the variable exactly as it found it, and a + // half-retyped module is not something a later decline could undo. + bool rewritable = true; + defUseMgr->ForEachUser(&global, [&](Instruction* user) { + if (!rewritable) return; + if (IsTypeAgnosticReference(user->opcode())) return; + if (user->opcode() == spv::Op::OpAccessChain || + user->opcode() == spv::Op::OpInBoundsAccessChain) { + // Only an access chain that lands ON the image - i.e. whose result is + // a pointer to the image type this pass is about to replace. + const Instruction* resultType = defUseMgr->GetDef(user->type_id()); + if (resultType == nullptr || resultType->opcode() != spv::Op::OpTypePointer || + resultType->GetSingleWordInOperand(kPointerPointeeOperand) != + chain.imageType->result_id()) { + rewritable = false; + return; + } + candidate.accessChains.push_back(user); + return; + } + if (user->opcode() == spv::Op::OpLoad) { + candidate.loads.push_back(user); + return; + } + // OpImageTexelPointer is DECLINED, not allowed through. Its result type + // does not depend on the format, but spirv-val requires the image behind + // an atomic to be r32i/r32ui/r32f, so baking any other format here would + // turn a module the validator accepts (Unknown is exempt) into one it + // rejects. GLSL cannot express an atomic on a format-less image anyway - + // the format qualifier is what makes an image atomic legal - so nothing + // reachable is being given up. + rewritable = false; + }); + if (!rewritable) continue; + + for (SizeT i = 0; i < candidate.accessChains.size() && rewritable; ++i) { + Instruction* accessChain = candidate.accessChains[i]; + defUseMgr->ForEachUser(accessChain, [&](Instruction* user) { + if (!rewritable) return; + if (IsTypeAgnosticReference(user->opcode())) return; + if (user->opcode() == spv::Op::OpLoad) { + candidate.loads.push_back(user); + return; + } + rewritable = false; + }); + } + if (!rewritable) continue; + + for (SizeT i = 0; i < candidate.loads.size() && rewritable; ++i) { + Instruction* load = candidate.loads[i]; + if (load->type_id() != chain.imageType->result_id()) { + rewritable = false; + break; + } + defUseMgr->ForEachUser(load, [&](Instruction* user) { + if (!rewritable) return; + if (IsTypeAgnosticReference(user->opcode())) return; + if (ConsumesImageValueWithoutRetyping(user->opcode())) return; + // Everything else - an image handed to a function, copied into a + // local, put in a composite - would need the type change carried + // further than this pass reasons about. + rewritable = false; + }); + } + if (!rewritable) continue; + + candidates.push_back(Move(candidate)); + } + + if (candidates.empty()) { + return Status::SuccessWithoutChange; + } + + // Type cloning. A new OpTypeImage is built by hand rather than through the type + // manager because the manager always writes the OpTypeImage Access Qualifier + // operand, which is a Kernel-capability operand: emitting it into a Shader module + // is what spirv-val rejects, not what the format change needed. + // + // Each clone is inserted immediately AFTER the instruction it was cloned from. + // That is what keeps the module free of forward references: everything the + // original depended on is already defined above it, and every user of the + // original - the OpVariable among them - is below it. + std::map, uint32_t> cloneCache; // (original type, key) -> id + + auto findIdenticalType = [&](const Instruction& candidateType) -> uint32_t { + for (const Instruction& type : irContext->types_values()) { + if (type.opcode() != candidateType.opcode()) continue; + if (type.NumInOperands() != candidateType.NumInOperands()) continue; + bool same = true; + for (uint32_t i = 0; i < type.NumInOperands(); ++i) { + if (type.GetInOperand(i).words != candidateType.GetInOperand(i).words) { + same = false; + break; + } + } + if (same) return type.result_id(); + } + return 0; + }; + + // The later of two instructions in the globals section. Both a new type's + // ORIGINAL and the definition of the operand it was given have to precede it, and + // the second of those can be a type the module declared further down (a join, see + // below) - so the two are compared rather than assumed. + auto laterInGlobals = [&](Instruction* a, Instruction* b) -> Instruction* { + if (a == nullptr) return b; + if (b == nullptr) return a; + Instruction* last = nullptr; + for (Instruction& global : irContext->types_values()) { + if (&global == a || &global == b) last = &global; + } + return last != nullptr ? last : a; + }; + + // Clones `original`, replacing in-operand `operandIndex` with `value`. Returns an + // EXISTING type id when the module already declares the result: two identical + // type declarations are invalid SPIR-V, and a module that already spells the + // wanted image (say a second uniform declared `layout(r32ui)`) must be JOINED to + // that declaration, not given a second one. + // + // Placement is the other half of staying valid. SPIR-V allows no forward + // reference among types, so a clone goes after whichever of its original and its + // new operand's definition comes last - the join case is exactly where those two + // differ, and putting the clone after the original alone is what left an + // OpVariable naming a pointer type declared below it. + auto cloneTypeWithOperand = [&](Instruction* original, uint32_t operandIndex, uint32_t value, + bool valueIsId) -> uint32_t { + const auto cacheKey = std::make_pair(original->result_id(), value); + const auto cached = cloneCache.find(cacheKey); + if (cached != cloneCache.end()) return cached->second; + + std::vector operands; + operands.reserve(original->NumInOperands()); + for (uint32_t i = 0; i < original->NumInOperands(); ++i) { + operands.push_back(original->GetInOperand(i)); + } + + auto clone = spvtools::MakeUnique(irContext, original->opcode(), 0, + irContext->TakeNextId(), operands); + if (clone->result_id() == 0) return 0; + clone->SetInOperand(operandIndex, {value}); + const uint32_t existing = findIdenticalType(*clone); + if (existing != 0) { + cloneCache.emplace(cacheKey, existing); + return existing; + } + const uint32_t newId = clone->result_id(); + // Only an ID operand names a definition the clone has to sit behind. The + // format operand is a LITERAL, and looking it up would resolve some unrelated + // instruction that happens to carry that number as its result id. + Instruction* anchor = + valueIsId ? laterInGlobals(original, defUseMgr->GetDef(value)) : original; + if (anchor == nullptr) return 0; + Instruction* inserted = clone.release(); + inserted->InsertAfter(anchor); + defUseMgr->AnalyzeInstDefUse(inserted); + cloneCache.emplace(cacheKey, newId); + return newId; + }; + + // Types the retype leaves behind. Killed at the end when nothing references them + // any more: a stranded Unknown-format image type is legal SPIR-V but is exactly + // the thing a later reader (this pass's own probe among them) would take for a + // shader that still needs baking. + std::vector possiblyOrphanedTypes; + + bool changed = false; + for (Candidate& candidate : candidates) { + const uint32_t newImageId = cloneTypeWithOperand(candidate.chain.imageType, kImageFormatOperand, + static_cast(candidate.format), + /*valueIsId=*/false); + if (newImageId == 0) return Status::Failure; + + // The pointer-to-image type every access chain and every single-image + // variable resolves through. + uint32_t newPointeeId = newImageId; + if (candidate.chain.arrayType != nullptr) { + newPointeeId = cloneTypeWithOperand(candidate.chain.arrayType, kArrayElementOperand, + newImageId, /*valueIsId=*/true); + if (newPointeeId == 0) return Status::Failure; + } + const uint32_t newVariablePointerId = cloneTypeWithOperand( + candidate.chain.pointerType, kPointerPointeeOperand, newPointeeId, /*valueIsId=*/true); + if (newVariablePointerId == 0) return Status::Failure; + + candidate.variable->SetResultType(newVariablePointerId); + defUseMgr->AnalyzeInstUse(candidate.variable); + // ...and move it behind that type, for the same no-forward-reference reason. + // A joined type can live anywhere in the globals section, including below the + // variable that now names it. Safe unconditionally because the only operand a + // UniformConstant OpVariable can have besides its storage class is an + // initializer, and a candidate carrying one was refused above. + if (Instruction* pointerTypeInst = defUseMgr->GetDef(newVariablePointerId); + pointerTypeInst != nullptr) { + candidate.variable->RemoveFromList(); + candidate.variable->InsertAfter(pointerTypeInst); + } + + for (Instruction* accessChain : candidate.accessChains) { + Instruction* oldResultType = defUseMgr->GetDef(accessChain->type_id()); + if (oldResultType == nullptr) return Status::Failure; + const uint32_t newResultType = + cloneTypeWithOperand(oldResultType, kPointerPointeeOperand, newImageId, /*valueIsId=*/true); + if (newResultType == 0) return Status::Failure; + accessChain->SetResultType(newResultType); + defUseMgr->AnalyzeInstUse(accessChain); + possiblyOrphanedTypes.push_back(oldResultType); + } + for (Instruction* load : candidate.loads) { + load->SetResultType(newImageId); + defUseMgr->AnalyzeInstUse(load); + } + // Innermost last, so the sweep below - which only kills what nothing + // references - can free a whole chain in one walk. + possiblyOrphanedTypes.push_back(candidate.chain.pointerType); + if (candidate.chain.arrayType != nullptr) { + possiblyOrphanedTypes.push_back(candidate.chain.arrayType); + } + possiblyOrphanedTypes.push_back(candidate.chain.imageType); + + // Every format outside the thirteen the Shader capability covers - the same + // thirteen GLSL ES has in core, which is not a coincidence: both lists are + // the formats Vulkan requires without an optional feature. Baking one of the + // rest without declaring the capability produces a module spirv-val rejects + // ("Operand 8 of TypeImage requires ... StorageImageExtendedFormats"), which + // is how the stencil half's r8ui announced itself. + if (!IsCoreEsslImageFormat(static_cast(candidate.format))) { + irContext->AddCapability(spv::Capability::StorageImageExtendedFormats); + } + changed = true; + } + + if (!changed) { + return Status::SuccessWithoutChange; + } + + // The types the retype stranded. Ordered outermost-first above and swept in that + // order, so a pointer goes before the image it pointed at and the image is + // unreferenced by the time it is reached. Anything still referenced - by another + // uniform this pass declined, or by an OpName - is simply left. + for (Instruction* orphan : possiblyOrphanedTypes) { + if (orphan == nullptr) continue; + if (defUseMgr->NumUsers(orphan) != 0) continue; + // Later entries may name the same instruction (several candidates sharing a + // type); scrub the duplicates before the pointer goes stale. + for (Instruction*& other : possiblyOrphanedTypes) { + if (other == orphan) other = nullptr; + } + irContext->KillInst(orphan); + } + + // StorageImageWriteWithoutFormat / StorageImageReadWithoutFormat are deliberately + // left declared. A capability a module no longer exercises is valid SPIR-V, and + // dropping one is only safe after proving no format-less image is left ANYWHERE - + // including the ones this pass declined - which is a stronger claim than the + // rewrite needs to make. + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken BakeImageFormatsPass::CreateBakeImageFormatsPass( + GLFormatByName glFormatByName) { + return spvtools::Optimizer::PassToken( + spvtools::MakeUnique(Move(glFormatByName))); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.h new file mode 100644 index 00000000..8de2ea8d --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.h @@ -0,0 +1,116 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.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 { + // Gives every format-less storage image in the module the format the application + // bound to its image unit, so SPIRV-Cross can print a format layout qualifier ESSL + // demands and desktop GLSL does not. + // + // Desktop GLSL 4.2 lets a `writeonly` (or `readonly`) image declaration omit the + // format qualifier - the access is typeless as far as the shader is concerned: + // + // writeonly uniform uimage2D uni_image; // legal desktop GLSL + // + // GLSL ES has no such relaxation. Every image uniform must carry one, and Adreno + // says so in as many words - "all images have to define layout format" - failing the + // whole program, which is how KHR-GL4x.packed_depth_stencil.stencil_texturing's + // compute half lost its only shader. + // + // The one format that is CORRECT to print is the one glBindImageTexture named for + // that unit: GL requires the shader qualifier, the bind format and the texture's own + // internal format to belong to the same format class, so the bind format is exactly + // what the declaration would have said had it been written out. It is not knowable + // at compile time, only at draw time, which is why this is a bake into the generated + // program rather than a translation: the caller keys its build on the (unit, format) + // pairs and rebuilds when a rebind moves one (BackendProgramObjectImpl, + // MG_Backend/DirectGLES). + // + // Where the bake happens is the OpTypeImage's Image Format operand, before + // SPIRV-Cross runs, rather than in the emitted text: SPIRV-Cross prints the operand + // it is given, so setting it is the whole of the change, and the result stays a + // valid module that spirv-val can still check. + // + // Deliberately narrow, on four axes: + // + // * UNKNOWN formats only. A declared format is authoritative - a `layout(r32ui)` + // image must be read as r32ui whatever the texture behind it is - and this pass + // never overrides one. It is also what keeps the rebuild key at zero for the + // overwhelming majority of programs. + // * STORAGE images (Sampled == 2). A sampled image's format operand must stay + // Unknown; it has no format qualifier in any GLSL dialect. + // * MATCHING component class only. spirv-val requires the Image Format's component + // type to agree with the OpTypeImage's Sampled Type, so a bind format that + // disagrees with the declaration (which GL leaves undefined) is DECLINED rather + // than baked into an invalid module. + // * ESSL only. Vulkan takes an Unknown-format storage image natively given + // shaderStorageImageWriteWithoutFormat, and Magma resolves the view format from + // the same bind state at descriptor time (UniformManager), so the module must + // reach that backend unchanged. + // + // A variable whose uses are not the plain access-chain / load / image-op shape - an + // image passed to a function, stored into a local - is DECLINED individually and + // left format-less, rather than half-retyped into a module no driver would accept. + // The decision is made before anything is mutated, so a decline costs nothing. + class BakeImageFormatsPass final : public spvtools::opt::Pass { + public: + // Uniform NAME to the GL internal format bound to the image unit it addresses + // (the `format` argument of glBindImageTexture). Names are the SPIR-V ones, i.e. + // an array is named once, without a subscript. Formats with no image-format + // spelling, and names the module does not declare, are ignored. + using GLFormatByName = UnorderedMap; + + explicit BakeImageFormatsPass(GLFormatByName glFormatByName) + : m_glFormatByName(Move(glFormatByName)) {} + + const char* name() const override { return "mobilegl-bake-image-formats"; } + Status Process() override; + + // Whether the module declares a storage image with no format at all, i.e. + // whether running this pass 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 DeclaresFormatlessStorageImage(const Vector& binary); + + // The GL internal format's SPIR-V ImageFormat, or 0 (Unknown) when the format + // has no image-format spelling. Exposed for the caller's ESSL-side question of + // whether an extension directive is needed for it. + static Uint32 SpirvImageFormatFromGLInternalFormat(Uint glInternalFormat); + + // Whether the SPIR-V ImageFormat is one GLSL ES has in core. The rest exist only + // under GL_NV_image_formats, whose directive the emitted ESSL must then carry. + // (It is also exactly the set that needs no StorageImageExtendedFormats + // capability in the module - both lists are the formats Vulkan requires without + // an optional feature.) + static bool IsCoreEsslImageFormat(Uint32 spirvImageFormat); + // Whether SPIRV-Cross will PRINT the format when it targets ESSL. Its + // is_desktop_only_format set throws instead of emitting, which loses the whole + // stage, so those formats are left for the text-level completion in the backend + // and are never baked into a module bound for SPIRV-Cross. A different question + // from IsCoreEsslImageFormat, and a different set. + static bool IsSpirvCrossEsslPrintableFormat(Uint32 spirvImageFormat); + // The ESSL layout-qualifier spelling of a GL internal format, or empty when the + // format has no image-format spelling. For the text-level completion above. + static String EsslSpellingOfGLInternalFormat(Uint glInternalFormat); + + static spvtools::Optimizer::PassToken CreateBakeImageFormatsPass(GLFormatByName glFormatByName); + + private: + GLFormatByName m_glFormatByName; + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL