diff --git a/CMakeLists.txt b/CMakeLists.txt index e40a8faa..7bc5f92c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -292,6 +292,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.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/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 55ddadf6..2df8db54 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -4923,6 +4923,44 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ConvertGLEnumToString(glShaderType).c_str()); } + // GL 4.6 core table 23.53 requires GL_MAX_SAMPLES >= 4, so every multisample + // ceiling MobileGL advertises is floored to 4 no matter what the ES driver + // reports - but the realised allocation cannot be, and + // ClampSamplesToBackendSupport quietly gives an integer or depth multisample + // texture the ONE sample Adreno and Mali actually support for it. A shader + // written against the advertised ceiling then fetches a sample that storage does + // not have and reads garbage; KHR-GL33/40/41.texture_swizzle.functional_* and + // KHR-GLxx.texture_size_promotion.functional bake exactly that literal in. Clamp + // the Sample operand to the backend-real per-category maximum so the fetch lands + // inside the allocation. Gated on some category actually being squeezed AND the + // module actually declaring a multisampled image, so no other stage pays an + // optimizer round trip for it. DirectVulkan is deliberately not given this: it + // allocates the sample count it was asked for, so its modules are already right. + Vector clampedSampleSpirv; + { + // Recomputed here rather than calling GL_Getter's GetAdvertisedMaxSamples(): + // this is backend code and must not reach into the GL frontend. 4 is that + // translation unit's kFrontendMaxSamples, which is the source of truth - + // keep the two in step. + constexpr Int kFrontendMaxSamples = 4; + const Int advertisedMaxSamples = + std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples); + if ((g_GLESCapabilities.MaxColorTextureSamples < advertisedMaxSamples || + g_GLESCapabilities.MaxIntegerSamples < advertisedMaxSamples || + g_GLESCapabilities.MaxDepthTextureSamples < advertisedMaxSamples) && + MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresMultisampledImage( + *effectiveSpirv) && + MG_Util::ShaderTranspiler::ShaderCompiler::ClampMultisampleFetchesForEssl( + *effectiveSpirv, clampedSampleSpirv, + g_GLESCapabilities.MaxColorTextureSamples, + g_GLESCapabilities.MaxIntegerSamples, + g_GLESCapabilities.MaxDepthTextureSamples, advertisedMaxSamples, + enableSpirvValidation) && + !clampedSampleSpirv.empty()) { + effectiveSpirv = &clampedSampleSpirv; + } + } + // GLSL ES has no ARRAY vertex inputs, and SPIRV-Cross refuses the whole module // rather than emulating them, so this has to happen before it sees the binary. Vector splitArrayInputSpirv; diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index 6cda0d75..29200a65 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable( DemoteFloat64Test.cpp FlattenXfbInterfaceBlocksTest.cpp LowerViewportIndexTest.cpp + ClampMultisampleFetchTest.cpp ) target_include_directories(SpirvPassTest PRIVATE diff --git a/MobileGL/MG_Test/ShaderTranspiler/ClampMultisampleFetchTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/ClampMultisampleFetchTest.cpp new file mode 100644 index 00000000..83f36bcf --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/ClampMultisampleFetchTest.cpp @@ -0,0 +1,363 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/ClampMultisampleFetchTest.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 +// +// ClampMultisampleFetchPass exists because MobileGL advertises one multisample ceiling and the ES +// driver underneath delivers another. GL 4.6 core table 23.53 forces GL_MAX_SAMPLES and +// GL_MAX_INTEGER_SAMPLES up to 4; Adreno and Mali back an integer multisample texture with ONE +// sample, and DirectGLES quietly allocates that (ClampSamplesToBackendSupport). A CTS shader that +// bakes in `texelFetch(usampler2DMS, coord, 3)` - which is what +// KHR-GL33/40/41.texture_swizzle.functional_* and KHR-GLxx.texture_size_promotion.functional do - +// then reads a sample the storage does not have. +// +// So what has to hold is per-fetch and per-category at once: the squeezed category's Sample +// operand must come back in range, a category that is not squeezed must be untouched, a module +// with no multisampled image at all must come out byte for byte as it went in, and every result +// must still be a valid module. Real GLSL through the same glslang path the backends use, for the +// same reason LowerViewportIndexTest.cpp does it: what matters is what glslang actually emits. + +#include + +#define SPV_ENABLE_UTILITY_CODE +#include "glslang/SPIRV/spirv.hpp11" +#undef SPV_ENABLE_UTILITY_CODE + +#include "Includes.h" +#include "Init.h" +#include +#include + +#include + +#include +#include +#include + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; + +namespace { + // GLSL.std.450 instruction number (see 3rdparty/glslang/SPIRV/GLSL.std.450.h). The signed + // minimum, which is what a GLSL `int` sample index asks for. + constexpr Uint32 kGlslStd450SMin = 39u; + + // What MobileGL tells the application GL_MAX_SAMPLES / GL_MAX_INTEGER_SAMPLES are, i.e. + // GL_Getter's kFrontendMaxSamples floor. Each test supplies its own backend-real ceilings + // against it; Adreno and Mali's Immortalis-G925 both really answer 1 for integer formats. + constexpr Int32 kAdvertisedMaxSamples = 4; + + constexpr SizeT kSpirvHeaderWordCount = 5u; + + template + void ForEachInstruction(const Vector& spirv, Visitor&& visit) { + for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) { + const Uint32 wordCount = spirv[offset] >> 16u; + if (wordCount == 0u || offset + wordCount > spirv.size()) break; + visit(static_cast(spirv[offset] & 0xffffu), &spirv[offset], wordCount); + offset += wordCount; + } + } + + Vector CompileFragment(const String& source) { + using namespace MobileGL::MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, + .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult || binaryResult->empty()) return {}; + return binaryResult->front(); + } + + String Disassemble(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + tools.Disassemble(spirv, &text); + return text; + } + + bool Validates(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + tools.SetMessageConsumer( + [](spv_message_level_t, const char*, const spv_position_t& position, const char* message) { + ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message; + }); + return tools.Validate(spirv); + } + + // OpImageFetch words: 0 opcode/count, 1 result type, 2 result id, 3 image, 4 coordinate, + // 5 the optional image-operands mask, 6.. the ids that mask asks for. + struct ImageFetch { + Uint32 resultId = 0u; + Uint32 imageId = 0u; + Uint32 mask = 0u; + Vector maskOperandIds; + }; + + Vector CollectImageFetches(const Vector& spirv) { + Vector fetches; + ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) { + if (opcode != spv::Op::OpImageFetch || wordCount < 5u) return; + ImageFetch fetch{}; + fetch.resultId = words[2]; + fetch.imageId = words[3]; + if (wordCount > 5u) { + fetch.mask = words[5]; + for (Uint32 word = 6u; word < wordCount; ++word) { + fetch.maskOperandIds.push_back(words[word]); + } + } + fetches.push_back(fetch); + }); + return fetches; + } + + // OpExtInst words: 0 opcode/count, 1 result type, 2 result id, 3 set, 4 instruction number, + // 5.. the operand ids. + struct ExtInst { + Uint32 resultId = 0u; + Uint32 instructionNumber = 0u; + Vector operandIds; + }; + + Vector CollectExtInsts(const Vector& spirv) { + Vector extInsts; + ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) { + if (opcode != spv::Op::OpExtInst || wordCount < 5u) return; + ExtInst extInst{}; + extInst.resultId = words[2]; + extInst.instructionNumber = words[4]; + for (Uint32 word = 5u; word < wordCount; ++word) { + extInst.operandIds.push_back(words[word]); + } + extInsts.push_back(extInst); + }); + return extInsts; + } + + std::map CollectScalarConstants(const Vector& spirv) { + std::map values; + ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) { + if (opcode == spv::Op::OpConstant && wordCount == 4u) values[words[2]] = words[3]; + }); + return values; + } + + // The one fetch carrying an explicit Sample operand. glslang emits Sample on its own for a + // multisample texelFetch - there is no texelFetchOffset for a multisampled sampler - so the + // sample id is the mask's first and only operand. + const ImageFetch* FindSampleCarryingFetch(const Vector& fetches) { + for (const ImageFetch& fetch : fetches) { + if ((fetch.mask & static_cast(spv::ImageOperandsMask::Sample)) != 0u) { + return &fetch; + } + } + return nullptr; + } + + const ImageFetch* FindLodCarryingFetch(const Vector& fetches) { + for (const ImageFetch& fetch : fetches) { + if ((fetch.mask & static_cast(spv::ImageOperandsMask::Lod)) != 0u) { + return &fetch; + } + } + return nullptr; + } + + // KHR-GL4x.texture_swizzle.functional's integer multisample read in miniature: the sample + // index is the advertised GL_MAX_INTEGER_SAMPLES - 1, baked in as a literal, which is exactly + // the value the one-sample allocation underneath cannot answer. The plain sampler2D fetch is + // the negative control - a NON-multisampled image whose Lod operand this pass must not touch. + const char* const kIntegerMultisampleFetch = R"(#version 410 core +uniform usampler2DMS uintMs; +uniform sampler2D plain; +out vec4 fragColor; +void main() { + uvec4 texel = texelFetch(uintMs, ivec2(gl_FragCoord.xy), 3); + vec4 other = texelFetch(plain, ivec2(gl_FragCoord.xy), 0); + fragColor = vec4(texel) * 0.5 + other; +} +)"; + + // The colour class, which real devices squeeze to something above 1 rather than to 1. + const char* const kColorMultisampleFetch = R"(#version 410 core +uniform sampler2DMS colorMs; +out vec4 fragColor; +void main() { + fragColor = texelFetch(colorMs, ivec2(gl_FragCoord.xy), 3); +} +)"; + + // Every stage on a squeezed device goes through the probe, so the one that declares no + // multisampled image has to come back untouched. + const char* const kNoMultisampleFetch = R"(#version 410 core +uniform sampler2D plain; +out vec4 fragColor; +void main() { + fragColor = texelFetch(plain, ivec2(gl_FragCoord.xy), 0); +} +)"; +} // namespace + +class ClampMultisampleFetchTest : public ::testing::Test { +protected: + void SetUp() override { + MobileGL::Initialize(); + m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount(); + } + + void TearDown() override { + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart) + << "the clamped module did not survive spirv-val"; + } + + Uint64 m_validationFailuresAtStart = 0; +}; + +// The probe is the gate that keeps every ordinary stage off an optimizer round trip, so it has to +// answer no for a shader that never reads a multisample texture - and yes for the ones that do. +TEST_F(ClampMultisampleFetchTest, TheProbeAnswersOnlyForAMultisampledImage) { + const Vector plain = CompileFragment(kNoMultisampleFetch); + ASSERT_FALSE(plain.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresMultisampledImage(plain)); + + const Vector integerMs = CompileFragment(kIntegerMultisampleFetch); + ASSERT_FALSE(integerMs.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresMultisampledImage(integerMs)); + + const Vector colorMs = CompileFragment(kColorMultisampleFetch); + ASSERT_FALSE(colorMs.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresMultisampledImage(colorMs)); + + // Runs on every stage of every program on a squeezed device, so it must survive a stage that + // produced no SPIR-V rather than pushing a parse diagnostic for it. + EXPECT_FALSE(ShaderCompiler::DeclaresMultisampledImage({})); +} + +// The overwhelming majority of modules. Behind the probe they never reach the pass at all, but the +// pass has to be inert for them on its own, or a future caller that forgets the gate silently +// re-serialises every shader in the program. +TEST_F(ClampMultisampleFetchTest, LeavesAModuleWithoutAMultisampledImageUntouched) { + const Vector input = CompileFragment(kNoMultisampleFetch); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::ClampMultisampleFetchesForEssl( + input, output, /*maxColorSamples=*/4, /*maxIntegerSamples=*/1, /*maxDepthSamples=*/4, + kAdvertisedMaxSamples, true)); + EXPECT_EQ(output, input) << Disassemble(output); +} + +// The bug itself. GL_MAX_INTEGER_SAMPLES says 4, the texture has one sample, and the shader asks +// for sample 3. +TEST_F(ClampMultisampleFetchTest, ReplacesAnOutOfRangeIntegerSampleWithZero) { + const Vector input = CompileFragment(kIntegerMultisampleFetch); + ASSERT_FALSE(input.empty()); + + const Vector before = CollectImageFetches(input); + ASSERT_EQ(before.size(), 2u) << Disassemble(input); + const ImageFetch* sampleBefore = FindSampleCarryingFetch(before); + const ImageFetch* lodBefore = FindLodCarryingFetch(before); + ASSERT_NE(sampleBefore, nullptr) << Disassemble(input); + ASSERT_NE(lodBefore, nullptr) << Disassemble(input); + ASSERT_EQ(sampleBefore->maskOperandIds.size(), 1u); + const std::map constantsBefore = CollectScalarConstants(input); + ASSERT_EQ(constantsBefore.count(sampleBefore->maskOperandIds.front()), 1u); + EXPECT_EQ(constantsBefore.at(sampleBefore->maskOperandIds.front()), 3u); + + Vector output; + ASSERT_TRUE(ShaderCompiler::ClampMultisampleFetchesForEssl( + input, output, /*maxColorSamples=*/4, /*maxIntegerSamples=*/1, /*maxDepthSamples=*/4, + kAdvertisedMaxSamples, true)); + ASSERT_FALSE(output.empty()); + const String dis = Disassemble(output); + ASSERT_TRUE(Validates(output)) << dis; + + const Vector after = CollectImageFetches(output); + ASSERT_EQ(after.size(), 2u) << dis; + const ImageFetch* sampleAfter = FindSampleCarryingFetch(after); + ASSERT_NE(sampleAfter, nullptr) << dis; + ASSERT_EQ(sampleAfter->maskOperandIds.size(), 1u) << dis; + + // Sample 0 is the only one a one-sample allocation has - and it is a CONSTANT, not a computed + // minimum: at K == 1 there is nothing to compare against. An id that resolves in the constant + // table cannot also be some OpExtInst's result. + const std::map constantsAfter = CollectScalarConstants(output); + ASSERT_EQ(constantsAfter.count(sampleAfter->maskOperandIds.front()), 1u) << dis; + EXPECT_EQ(constantsAfter.at(sampleAfter->maskOperandIds.front()), 0u) << dis; + + // The float sampler2D in the same module is not multisampled, so its Lod fetch has to come + // through with the same image, the same mask and the same operand. + const ImageFetch* lodAfter = FindLodCarryingFetch(after); + ASSERT_NE(lodAfter, nullptr) << dis; + EXPECT_EQ(lodAfter->imageId, lodBefore->imageId) << dis; + EXPECT_EQ(lodAfter->mask, lodBefore->mask) << dis; + EXPECT_EQ(lodAfter->maskOperandIds, lodBefore->maskOperandIds) << dis; +} + +// The same shader on a device whose integer ceiling really is what MobileGL advertises. Nothing is +// out of range, so nothing may be rewritten - and the module must not even be re-serialised. +TEST_F(ClampMultisampleFetchTest, LeavesTheFetchAloneWhenTheCategoryReachesTheAdvertisedMaximum) { + const Vector input = CompileFragment(kIntegerMultisampleFetch); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::ClampMultisampleFetchesForEssl( + input, output, /*maxColorSamples=*/4, /*maxIntegerSamples=*/4, /*maxDepthSamples=*/4, + kAdvertisedMaxSamples, true)); + EXPECT_EQ(output, input) << Disassemble(output); +} + +// A category squeezed to something above 1 cannot be answered with a constant: an index the +// allocation does have must survive, so only the upper bound moves. +TEST_F(ClampMultisampleFetchTest, ClampsAColorSampleWithAMinimum) { + const Vector input = CompileFragment(kColorMultisampleFetch); + ASSERT_FALSE(input.empty()); + + const Vector before = CollectImageFetches(input); + ASSERT_EQ(before.size(), 1u) << Disassemble(input); + ASSERT_EQ(before.front().maskOperandIds.size(), 1u); + const Uint32 originalSampleId = before.front().maskOperandIds.front(); + + Vector output; + ASSERT_TRUE(ShaderCompiler::ClampMultisampleFetchesForEssl( + input, output, /*maxColorSamples=*/2, /*maxIntegerSamples=*/4, /*maxDepthSamples=*/4, + kAdvertisedMaxSamples, true)); + ASSERT_FALSE(output.empty()); + const String dis = Disassemble(output); + ASSERT_TRUE(Validates(output)) << dis; + + const Vector after = CollectImageFetches(output); + ASSERT_EQ(after.size(), 1u) << dis; + ASSERT_EQ(after.front().maskOperandIds.size(), 1u) << dis; + const Uint32 clampedSampleId = after.front().maskOperandIds.front(); + EXPECT_NE(clampedSampleId, originalSampleId) << dis; + + const Vector extInsts = CollectExtInsts(output); + const ExtInst* minimum = nullptr; + for (const ExtInst& extInst : extInsts) { + if (extInst.resultId == clampedSampleId) minimum = &extInst; + } + ASSERT_NE(minimum, nullptr) << dis; + EXPECT_EQ(minimum->instructionNumber, kGlslStd450SMin) << dis; + ASSERT_EQ(minimum->operandIds.size(), 2u) << dis; + EXPECT_EQ(minimum->operandIds[0], originalSampleId) << dis; + + // min(sample, K - 1), i.e. the last sample a two-sample allocation has. + const std::map constants = CollectScalarConstants(output); + ASSERT_EQ(constants.count(minimum->operandIds[1]), 1u) << dis; + EXPECT_EQ(constants.at(minimum->operandIds[1]), 1u) << dis; +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index e3ecdd9e..26420811 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -33,6 +33,7 @@ #include "SpirvPasses/NormalizeRectCoordinatesPass.h" #include "SpirvPasses/Lower1DArrayImagesPass.h" #include "SpirvPasses/BakeImageFormatsPass.h" +#include "SpirvPasses/ClampMultisampleFetchPass.h" #include "SpirvPasses/PrivateToEntryLocalPass.h" #include "SpirvPasses/StripUniformLocationsPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" @@ -651,6 +652,26 @@ namespace MobileGL { return LowerViewportIndexPass::DeclaresViewportIndexBuiltin(binary); } + bool ShaderCompiler::ClampMultisampleFetchesForEssl(const Vector& inputBinary, + Vector& outputBinary, + const Int32 maxColorSamples, + const Int32 maxIntegerSamples, + const Int32 maxDepthSamples, + const Int32 advertisedMaxSamples, + const bool enableSpirvValidation) { + using namespace spvtools; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(ClampMultisampleFetchPass::CreateClampMultisampleFetchPass( + maxColorSamples, maxIntegerSamples, maxDepthSamples, advertisedMaxSamples)); + + return RunOptimizerChecked("ClampMultisampleFetchesForEssl", optimizer, inputBinary, + outputBinary, true, enableSpirvValidation); + } + + bool ShaderCompiler::DeclaresMultisampledImage(const Vector& binary) { + return ClampMultisampleFetchPass::DeclaresMultisampledImage(binary); + } + bool ShaderCompiler::SplitArrayVertexInputsForEssl(const Vector& inputBinary, Vector& outputBinary, const bool enableSpirvValidation) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index fb52679c..3cabe30c 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -46,6 +46,25 @@ namespace MobileGL { // whether the pass above has anything to do. The gate that keeps every other // stage off an optimizer round trip it does not need. static bool DeclaresViewportIndexBuiltin(const Vector& binary); + // Clamps the Sample image-operand of every multisample fetch to the sample count + // the BACKEND can really deliver for that image's category, which on Adreno and + // Mali is 1 for integer formats while the frontend advertises the GL-mandated + // floor of 4. Without it a `texelFetch(usampler2DMS, coord, 3)` reads past the + // end of a one-sample allocation. Pass the backend-real per-category ceilings and + // the advertised maximum (GL_Getter's GetAdvertisedMaxSamples); a category that + // already reaches the advertised value is left alone. DirectGLES transpile path + // only. See ClampMultisampleFetchPass. + static bool ClampMultisampleFetchesForEssl(const Vector& inputBinary, + Vector& outputBinary, + Int32 maxColorSamples, + Int32 maxIntegerSamples, + Int32 maxDepthSamples, + Int32 advertisedMaxSamples, + bool enableSpirvValidation = false); + // Whether the module declares any multisampled image type, i.e. whether the pass + // above has anything to do. The gate that keeps every other stage off an + // optimizer round trip it does not need. + static bool DeclaresMultisampledImage(const Vector& binary); // Replaces an ARRAY vertex input with one input per element at consecutive // locations, seeding a Private copy of the array so indexed reads still work. // GLSL ES has no array vertex inputs and SPIRV-Cross refuses the whole module diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp new file mode 100644 index 00000000..c1e2cbf7 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp @@ -0,0 +1,374 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.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 "ClampMultisampleFetchPass.h" + +#include "spirv.hpp" +#include "source/opt/build_module.h" +#include "source/opt/constants.h" +#include "source/opt/def_use_manager.h" +#include "source/opt/instruction.h" +#include "source/opt/ir_builder.h" +#include "source/opt/ir_context.h" +#include "source/opt/module.h" +#include "source/opt/type_manager.h" +#include "source/opt/types.h" +#include "source/util/make_unique.h" +#include "source/util/string_utils.h" + +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::InstructionBuilder; + using spvtools::opt::IRContext; + namespace analysis = spvtools::opt::analysis; + + // GLSL.std.450 instruction numbers (see 3rdparty/glslang/SPIRV/GLSL.std.450.h). + // UMin is not interchangeable with SMin here: an unsigned operand large enough to + // read as negative would come back OUT of range from a signed minimum, which is + // the very thing this pass exists to prevent. + constexpr uint32_t kGlslUMin = 38u; + constexpr uint32_t kGlslSMin = 39u; + + // OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS, + // 5 Sampled, 6 Format. + constexpr uint32_t kSampledTypeOperand = 0; + constexpr uint32_t kDepthOperand = 2; + constexpr uint32_t kMultisampledOperand = 4; + + // OpImageFetch / OpImageRead in-operands: 0 image, 1 coordinate, 2 the optional + // image-operands mask, 3.. the ids that mask asks for. + constexpr uint32_t kImageOperand = 0; + constexpr uint32_t kImageOperandsMaskOperand = 2; + + // The categories GL keeps a separate GL_MAX_*_SAMPLES ceiling for. + enum class SampleCategory { Color, Depth, Integer }; + + // The two operations that can carry a Sample image-operand and take their + // coordinate in in-operand 1. OpImageWrite can carry one too, but its operand + // layout differs (image, coordinate, TEXEL, mask) and writing a multisample + // texel is not expressible in the ESSL this backend emits, so it is left out + // rather than given an untested second index arithmetic. The sparse forms are + // out of scope for the same reason: ESSL has no sparse texturing at all, so a + // module containing one cannot reach a driver through this path anyway. + bool CarriesSampleImageOperand(spv::Op opcode) { + return opcode == spv::Op::OpImageFetch || opcode == spv::Op::OpImageRead; + } + + bool IsMultisampledImageType(const Instruction* imageType) { + return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage && + imageType->NumInOperands() > kMultisampledOperand && + imageType->GetSingleWordInOperand(kMultisampledOperand) == 1u; + } + + // The OpTypeImage behind whatever an image operation was handed - a sampled + // image, a bare image, or a pointer to (or array of) either. Same unwrapping as + // Lower1DArrayImagesPass. + Instruction* ResolveImageType(IRContext* context, uint32_t objectId) { + auto* defUseMgr = context->get_def_use_mgr(); + Instruction* object = defUseMgr->GetDef(objectId); + if (object == nullptr) return nullptr; + Instruction* type = defUseMgr->GetDef(object->type_id()); + while (type != nullptr) { + switch (type->opcode()) { + case spv::Op::OpTypeImage: + return type; + case spv::Op::OpTypeSampledImage: + case spv::Op::OpTypePointer: + case spv::Op::OpTypeArray: + case spv::Op::OpTypeRuntimeArray: + // Each names its element type in its last in-operand, except arrays, + // whose element type is the FIRST. + type = defUseMgr->GetDef(type->opcode() == spv::Op::OpTypeArray || + type->opcode() == spv::Op::OpTypeRuntimeArray + ? type->GetSingleWordInOperand(0) + : type->GetSingleWordInOperand(type->NumInOperands() - 1)); + continue; + default: + return nullptr; + } + } + return nullptr; + } + + SampleCategory CategoryOf(IRContext* context, const Instruction* imageType) { + const Instruction* sampledType = + context->get_def_use_mgr()->GetDef(imageType->GetSingleWordInOperand(kSampledTypeOperand)); + if (sampledType != nullptr && sampledType->opcode() == spv::Op::OpTypeInt) { + return SampleCategory::Integer; + } + // Depth == 1 is the ONLY spelling that positively means a depth image. + // glslang writes 0 for a plain sampler and 2 ("no indication") wherever it + // cannot tell, and GLSL has no multisampled shadow sampler for it to write 1 + // for, so everything but an explicit 1 falls to the colour ceiling - which is + // also the safer of the two to guess at, being the one GL_MAX_SAMPLES itself + // describes. Guarded because Depth is only readable on a well-formed type. + if (imageType->NumInOperands() > kDepthOperand && + imageType->GetSingleWordInOperand(kDepthOperand) == 1u) { + return SampleCategory::Depth; + } + return SampleCategory::Color; + } + + // Where the Sample id sits among an image operation's in-operands, or false when + // the operation carries no Sample at all. + // + // The position is NOT fixed. The mask's ids follow it in ASCENDING BIT ORDER, so + // every lower bit that is set pushes Sample along by the number of ids that bit + // asks for: Bias/Lod/ConstOffset/Offset/ConstOffsets one each, Grad two (dx and + // dy). Bits at or above Sample cannot move it and are irrelevant here. glslang + // only ever emits Sample on its own for a GLSL texelFetch - there is no + // texelFetchOffset for a multisampled sampler - so in practice this always + // answers 3; the walk is what keeps that from being an assumption. + bool TryGetSampleOperandIndex(const Instruction& instruction, uint32_t* sampleOperandIndex) { + if (instruction.NumInOperands() <= kImageOperandsMaskOperand) { + // No image-operands mask at all, so no explicit sample: SPIR-V reads + // sample 0, which is in range of any allocation. Nothing to clamp. + return false; + } + const uint32_t mask = instruction.GetSingleWordInOperand(kImageOperandsMaskOperand); + const auto has = [mask](spv::ImageOperandsMask bit) { + return (mask & static_cast(bit)) != 0u; + }; + if (!has(spv::ImageOperandsMask::Sample)) { + return false; + } + + uint32_t index = kImageOperandsMaskOperand + 1; + if (has(spv::ImageOperandsMask::Bias)) ++index; + if (has(spv::ImageOperandsMask::Lod)) ++index; + if (has(spv::ImageOperandsMask::Grad)) index += 2; + if (has(spv::ImageOperandsMask::ConstOffset)) ++index; + if (has(spv::ImageOperandsMask::Offset)) ++index; + if (has(spv::ImageOperandsMask::ConstOffsets)) ++index; + if (instruction.NumInOperands() <= index) { + // A mask promising more operands than the instruction carries is a + // malformed module; leave it to the validator rather than indexing past + // the end of it. + return false; + } + *sampleOperandIndex = index; + return true; + } + + // The module's GLSL.std.450 import, creating it when the module has none. + // glslang emits one for all but the most trivial shaders, but a module that + // reached here without one must still be clampable. 0 means no id was available, + // and in that case NOTHING was added - the caller can still leave the module + // untouched. IRContext::AddExtInstImport rather than Module's: it is the one that + // keeps the def-use and feature managers in step with the new import. + uint32_t EnsureGlslStd450Import(IRContext* context) { + for (const Instruction& import : context->module()->ext_inst_imports()) { + if (spvtools::utils::MakeString(import.GetInOperand(0).words) == "GLSL.std.450") { + return import.result_id(); + } + } + const uint32_t importId = context->TakeNextId(); + if (importId == 0u) return 0u; + context->AddExtInstImport(spvtools::MakeUnique( + context, spv::Op::OpExtInstImport, 0, importId, + Instruction::OperandList{ + {SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector("GLSL.std.450")}})); + return importId; + } + } // namespace + + bool ClampMultisampleFetchPass::DeclaresMultisampledImage(const Vector& binary) { + if (binary.empty()) { + // An empty module is a stage that produced no SPIR-V, which is not a verdict + // about multisample fetches; letting BuildModule reject it would push a + // spurious diagnostic through the message consumer first. + return false; + } + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, [](spv_message_level_t, const char*, const spv_position_t&, const char*) {}, + binary.data(), binary.size()); + if (!context) { + // Unparseable here means unusable downstream too; let the ordinary transpile + // path produce the error rather than inventing a verdict from it. + return false; + } + for (const Instruction& type : context->module()->types_values()) { + if (IsMultisampledImageType(&type)) { + return true; + } + } + return false; + } + + spvtools::opt::Pass::Status ClampMultisampleFetchPass::Process() { + // No category is squeezed, so no fetch can be out of range. This is the whole + // answer on a driver whose per-format ceilings all reach what MobileGL + // advertises, and it costs nothing. + if (m_maxColorSamples >= m_advertisedMaxSamples && + m_maxIntegerSamples >= m_advertisedMaxSamples && + m_maxDepthSamples >= m_advertisedMaxSamples) { + return Status::SuccessWithoutChange; + } + + auto* irContext = context(); + + // The type table settles it for almost every shader: no multisampled image + // declared, nothing any fetch in the body could be reading. + bool hasMultisampledImageType = false; + for (const Instruction& type : irContext->types_values()) { + if (IsMultisampledImageType(&type)) { + hasMultisampledImageType = true; + break; + } + } + if (!hasMultisampledImageType) { + return Status::SuccessWithoutChange; + } + + auto* defUseMgr = irContext->get_def_use_mgr(); + auto* typeMgr = irContext->get_type_mgr(); + auto* constantMgr = irContext->get_constant_mgr(); + + bool clampedAnything = false; + for (auto& function : *irContext->module()) { + for (auto& block : function) { + for (auto& instruction : block) { + if (!CarriesSampleImageOperand(instruction.opcode()) || + instruction.NumInOperands() <= kImageOperandsMaskOperand) { + continue; + } + const Instruction* imageType = + ResolveImageType(irContext, instruction.GetSingleWordInOperand(kImageOperand)); + if (!IsMultisampledImageType(imageType)) { + continue; + } + uint32_t sampleOperandIndex = 0; + if (!TryGetSampleOperandIndex(instruction, &sampleOperandIndex)) { + continue; + } + + Int32 categoryMaxSamples = m_maxColorSamples; + switch (CategoryOf(irContext, imageType)) { + case SampleCategory::Integer: + categoryMaxSamples = m_maxIntegerSamples; + break; + case SampleCategory::Depth: + categoryMaxSamples = m_maxDepthSamples; + break; + case SampleCategory::Color: + break; + } + if (categoryMaxSamples >= m_advertisedMaxSamples) { + continue; + } + + // The replacement has to carry the ORIGINAL operand's type: SPIR-V + // permits either signedness for Sample, and handing OpImageFetch an + // int where it had a uint is an invalid module rather than a wrong + // answer - the kind of defect that reaches a driver as "compiles + // here, not there". + const uint32_t sampleOperandId = instruction.GetSingleWordInOperand(sampleOperandIndex); + const Instruction* sampleOperandDef = defUseMgr->GetDef(sampleOperandId); + if (sampleOperandDef == nullptr) { + continue; + } + const uint32_t sampleTypeId = sampleOperandDef->type_id(); + const analysis::Type* sampleType = + sampleTypeId != 0u ? typeMgr->GetType(sampleTypeId) : nullptr; + const analysis::Integer* sampleInteger = + sampleType != nullptr ? sampleType->AsInteger() : nullptr; + if (sampleInteger == nullptr || sampleInteger->width() != 32u) { + // GLSL spells the sample index `int` and SPIR-V requires an + // integer scalar, so this is unreachable from any shader this + // backend compiles. Declining beats minting a constant of a + // width the operand never had. + MGLOG_D("ClampMultisampleFetchPass: sample operand %%%u of a " + "multisample fetch is not a 32-bit integer scalar; left " + "unclamped.", + sampleOperandId); + continue; + } + + if (categoryMaxSamples <= 1) { + // One sample exists, and its index is 0. + const analysis::Constant* zero = constantMgr->GetConstant(sampleType, {0u}); + const Instruction* zeroInst = + zero != nullptr ? constantMgr->GetDefiningInstruction(zero, sampleTypeId) + : nullptr; + if (zeroInst == nullptr) { + return Status::Failure; + } + instruction.SetInOperand(sampleOperandIndex, {zeroInst->result_id()}); + irContext->UpdateDefUse(&instruction); + clampedAnything = true; + continue; + } + + // min(operand, K-1). Only the upper bound: an index already inside + // the allocation comes through untouched, which is what makes this + // safe to apply to a shader that was already correct. + // + // Everything from here on either completes or fails the module. + // Anything that gives up half way - after the import or the bound + // constant has been added - would leave a MUTATED module reported as + // SuccessWithoutChange, which spvtools::Optimizer asserts against + // (it re-serialises and compares byte for byte in that case). + const uint32_t glslStd450Id = EnsureGlslStd450Import(irContext); + if (glslStd450Id == 0u) { + // Id space exhausted, and the import was NOT added. Nothing has + // changed yet, but nothing further can be built either. + return Status::Failure; + } + const uint32_t resultId = irContext->TakeNextId(); + const analysis::Constant* bound = constantMgr->GetConstant( + sampleType, {static_cast(categoryMaxSamples - 1)}); + const Instruction* boundInst = + bound != nullptr ? constantMgr->GetDefiningInstruction(bound, sampleTypeId) + : nullptr; + if (resultId == 0u || boundInst == nullptr) { + return Status::Failure; + } + + InstructionBuilder builder( + irContext, &instruction, + IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + Instruction* clamped = builder.AddInstruction(spvtools::MakeUnique( + irContext, spv::Op::OpExtInst, sampleTypeId, resultId, + Instruction::OperandList{ + {SPV_OPERAND_TYPE_ID, {glslStd450Id}}, + {SPV_OPERAND_TYPE_EXTENSION_INSTRUCTION_NUMBER, + {sampleInteger->IsSigned() ? kGlslSMin : kGlslUMin}}, + {SPV_OPERAND_TYPE_ID, {sampleOperandId}}, + {SPV_OPERAND_TYPE_ID, {boundInst->result_id()}}})); + if (clamped == nullptr) { + return Status::Failure; + } + instruction.SetInOperand(sampleOperandIndex, {clamped->result_id()}); + irContext->UpdateDefUse(&instruction); + clampedAnything = true; + } + } + } + + if (!clampedAnything) { + return Status::SuccessWithoutChange; + } + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken ClampMultisampleFetchPass::CreateClampMultisampleFetchPass( + const Int32 maxColorSamples, const Int32 maxIntegerSamples, const Int32 maxDepthSamples, + const Int32 advertisedMaxSamples) { + return spvtools::Optimizer::PassToken(spvtools::MakeUnique( + maxColorSamples, maxIntegerSamples, maxDepthSamples, advertisedMaxSamples)); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.h new file mode 100644 index 00000000..4823baca --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.h @@ -0,0 +1,89 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // GL 4.6 core table 23.53 requires GL_MAX_SAMPLES >= 4, so MobileGL floors every + // multisample ceiling it advertises to 4 (GL_Getter's kFrontendMaxSamples) no matter + // what the ES driver reports. The realised allocation cannot be floored the same way - + // the driver would simply reject it - so DirectGLES clamps the count it passes to + // glTexStorage*Multisample down to what the format really supports + // (ClampSamplesToBackendSupport). On Adreno and on Mali's Immortalis-G925 that is ONE + // sample for every integer format, while the frontend keeps telling the application + // GL_MAX_INTEGER_SAMPLES is 4. + // + // A shader written against the advertised ceiling therefore fetches a sample the + // backing storage does not have. KHR-GL33/40/41.texture_swizzle.functional_* and + // KHR-GLxx.texture_size_promotion.functional bake `texelFetch(usampler2DMS, coord, 3)` + // in as a literal, and the fetch comes back as 0/garbage ("Found pixel with wrong + // value", "read value = 0") on a texture the backend quietly allocated with one + // sample. + // + // This pass closes that gap from the shader side: for every fetch of a multisampled + // image it clamps the Sample image-operand to the backend's REAL maximum for that + // image's category, so the lookup lands inside the allocation the backend made. + // - K >= advertisedMaxSamples: the category is not squeezed, nothing is rewritten. + // - K <= 1: the Sample operand becomes a constant 0 of its own type - the only + // sample that exists. + // - 1 < K < advertisedMaxSamples: the operand is wrapped in min(operand, K-1), + // which leaves an in-range index exactly as it was. + // Only the UPPER bound is clamped. A negative index is out of range in GL before this + // pass and after it alike, and MobileGL is not the component that should be inventing + // a value for it. + // + // Category comes from the OpTypeImage: an OpTypeInt sampled type is the integer + // class (GL_MAX_INTEGER_SAMPLES), a float one is depth when the image's Depth operand + // is exactly 1 and colour otherwise. That last clause is deliberate: glslang writes + // Depth 0 for a plain sampler and 2 ("unknown") wherever it cannot tell, and GLSL has + // no multisampled shadow sampler at all, so only an explicit 1 is treated as a depth + // image and everything else falls to the colour limit - which is the one a + // mis-classified image would want anyway. + // + // DirectGLES transpile path only. DirectVulkan allocates the sample count it was + // asked for and must see the module unchanged. + class ClampMultisampleFetchPass : public spvtools::opt::Pass { + public: + // The three backend-REAL per-category ceilings, plus the count the GL frontend + // advertises (GL_Getter's GetAdvertisedMaxSamples). A category whose real ceiling + // already reaches the advertised one is left completely alone. + ClampMultisampleFetchPass(Int32 maxColorSamples, Int32 maxIntegerSamples, + Int32 maxDepthSamples, Int32 advertisedMaxSamples) + : m_maxColorSamples(maxColorSamples), + m_maxIntegerSamples(maxIntegerSamples), + m_maxDepthSamples(maxDepthSamples), + m_advertisedMaxSamples(advertisedMaxSamples) {} + + const char* name() const override { return "clamp-multisample-fetch"; } + Status Process() override; + + // Whether the module declares any multisampled image type, i.e. whether running + // this pass could change anything. Answered from a single parse so the caller can + // skip the optimizer round trip entirely - which is every shader but the handful + // that read a multisample texture directly. + static bool DeclaresMultisampledImage(const Vector& binary); + + static spvtools::Optimizer::PassToken CreateClampMultisampleFetchPass( + Int32 maxColorSamples, Int32 maxIntegerSamples, Int32 maxDepthSamples, + Int32 advertisedMaxSamples); + + private: + Int32 m_maxColorSamples; + Int32 m_maxIntegerSamples; + Int32 m_maxDepthSamples; + Int32 m_advertisedMaxSamples; + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL