From 9bd125aeec7c1b72386534ac75ab8bc68801e437 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 21:21:54 -0400 Subject: [PATCH 1/6] [Fix, Test] (ShaderTranspiler): carry r11f_g11f_b10f storage images in rgba16f instead of losing the stage --- .../WidenImageFormatsTest.cpp | 108 +++++++++++++++--- .../SpirvPasses/WidenImageFormatsPass.cpp | 45 ++++++-- .../SpirvPasses/WidenImageFormatsPass.h | 23 +++- 3 files changed, 148 insertions(+), 28 deletions(-) diff --git a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp index 8f94f164..e73c346d 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp @@ -14,10 +14,10 @@ // and every draw with the program silently renders nothing while GL_LINK_STATUS still says TRUE. // // What has to hold is the emulation's exactness, in three parts at once: the DECLARED format must -// become the core carrier of the same per-channel width, every imageStore through it must have its -// surplus components replaced by GL's own (0.., 1) so the carrier's extra channels never hold -// anything GL has not defined, and every imageLoad must come back masked the same way. A module -// that declares only core formats - or one of the nine formats with no exact carrier - must come +// become a core carrier that loses nothing, every imageStore through it must have its surplus +// components replaced by GL's own (0.., 1) so the carrier's extra channels never hold anything GL +// has not defined, and every imageLoad must come back masked the same way. A module that declares +// only core formats - or one of the eight formats with no lossless carrier at all - must come // out untouched, because widening those would be an approximation rather than an emulation. Real // GLSL through the same glslang path the backends use, for the same reason // ClampMultisampleFetchTest.cpp does it: what matters is what glslang actually emits. @@ -229,9 +229,23 @@ void main() { } )"; - // rg16 is one of the NINE with no core carrier of the same per-channel width. Widening it - // would change the quantisation an application sees, so it must be left alone and keep the - // honest "no GLSL ES spelling" diagnostic instead. + // r11f_g11f_b10f: THREE float channels in a packed 32-bit word, and the only format the four + // CTS allFormats/allTargets walkers still aborted on after the channel widening landed - it + // has no core carrier of the same per-channel width, so it took rgba16f, whose 5-bit exponent + // and longer mantissa represent every 11f and 10f value exactly. + const char* const kR11fG11fB10fLoadStore = R"(#version 430 core +layout(r11f_g11f_b10f, binding = 0) uniform image2D img; +out vec4 fragColor; +void main() { + vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy)); + imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0)); + fragColor = texel; +} +)"; + + // rg16 is one of the EIGHT with no core carrier at all - core ESSL has no 16-bit normalized + // format, so every candidate loses range or changes the component type the texture presents. + // It must be left alone and keep the honest "no GLSL ES spelling" diagnostic instead. const char* const kRg16LoadStore = R"(#version 430 core layout(rg16, binding = 0) uniform image2D img; out vec4 fragColor; @@ -247,7 +261,7 @@ void main() { // the shader rewrite, the ES texture storage and the glBindImageTexture argument. If it drifts // the three stop agreeing, and a narrow texture read through a wide image goes out of bounds // silently on every driver tested. -TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) { +TEST(WidenImageFormats, EighteenNonCoreFormatsHaveALosslessCoreCarrier) { struct Case { Uint requested; Uint carrier; @@ -272,6 +286,10 @@ TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) { {0x8234, 0x8D76, 1, "GL_R16UI -> GL_RGBA16UI"}, {0x8238, 0x8D7C, 2, "GL_RG8UI -> GL_RGBA8UI"}, {0x8232, 0x8D7C, 1, "GL_R8UI -> GL_RGBA8UI"}, + // The one entry that is a re-encoding rather than a channel widening: 11f is e5m6 and 10f + // is e5m5 against a half's s1e5m10, so the carrier is still lossless - and three channels, + // so the mask has to pin only alpha. + {0x8C3A, 0x881A, 3, "GL_R11F_G11F_B10F -> GL_RGBA16F"}, }; for (const Case& testCase : cases) { EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(testCase.requested), testCase.carrier) @@ -287,7 +305,7 @@ TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) { } } -TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) { +TEST(WidenImageFormats, CoreFormatsAndTheEightWithoutALosslessCarrierAreRefused) { // The thirteen GLSL ES already has: nothing to carry. for (const Uint coreFormat : {0x8814u /*RGBA32F*/, 0x881Au /*RGBA16F*/, 0x822Eu /*R32F*/, 0x8058u /*RGBA8*/, 0x8F97u /*RGBA8_SNORM*/, 0x8D82u /*RGBA32I*/, @@ -297,10 +315,12 @@ TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) { EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(coreFormat), 0u) << "core format 0x" << std::hex << coreFormat; } - // The nine with no core format of the same per-channel width. Carrying these would be an - // approximation - a different quantisation, or a different numeric domain for anything that - // samples the same texture - so they are deliberately left to the honest diagnostic. - for (const Uint hardFormat : {0x8C3Au /*R11F_G11F_B10F*/, 0x8059u /*RGB10_A2*/, + // The eight with no LOSSLESS core carrier: core ESSL has no 16-bit normalized format and no + // 10-bit one, so every candidate for these either loses range or changes the component type + // the texture presents to anything that samples it. Deliberately left to the honest + // diagnostic. r11f_g11f_b10f is NOT among them - rgba16f holds every value it can, so it is + // carried above. + for (const Uint hardFormat : {0x8059u /*RGB10_A2*/, 0x906Fu /*RGB10_A2UI*/, 0x805Bu /*RGBA16*/, 0x822Cu /*RG16*/, 0x822Au /*R16*/, 0x8F9Bu /*RGBA16_SNORM*/, 0x8F99u /*RG16_SNORM*/, 0x8F98u /*R16_SNORM*/}) { @@ -357,6 +377,68 @@ TEST(WidenImageFormats, TwoChannelFloatImageBecomesRgba32fWithBothAccessesMasked << "the mask must be a separate value, or it would feed itself"; } +// The three-channel case, which no format exercised before r11f_g11f_b10f was carried: only ALPHA +// is surplus, so the mask must take r, g and b from the texel and nothing but the fourth component +// from the (0, 0, 0, 1) constant. A mask that zeroed blue here - the shape a two-channel format +// wants - would silently drop the third channel of every store. +TEST(WidenImageFormats, ThreeChannelPackedFloatImageBecomesRgba16fWithOnlyAlphaPinned) { + const Vector spirv = CompileFragment(kR11fG11fB10fLoadStore); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); + + const auto beforeTypes = CollectStorageImageTypes(spirv); + ASSERT_EQ(beforeTypes.size(), 1u); + EXPECT_EQ(beforeTypes.front().format, static_cast(spv::ImageFormat::R11fG11fB10f)); + + Vector widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false, + /*enableSpirvValidation=*/true)); + ASSERT_FALSE(widened.empty()); + EXPECT_TRUE(Validates(widened)); + EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(widened)); + + const auto afterTypes = CollectStorageImageTypes(widened); + ASSERT_EQ(afterTypes.size(), 1u); + EXPECT_EQ(afterTypes.front().format, static_cast(spv::ImageFormat::Rgba16f)); + + const auto shuffles = CollectVectorShuffles(widened); + + const auto texelIds = CollectImageWriteTexelIds(widened); + ASSERT_EQ(texelIds.size(), 1u); + const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front()); + ASSERT_NE(storeMask, nullptr) << "the imageStore texel is not a masked value"; + EXPECT_TRUE(HasComponents(*storeMask, {0u, 1u, 2u, 7u})) + << "expected (r, g, b, 1) - components 0, 1 and 2 of the texel, then 3 of (0,0,0,1)"; + + const auto readIds = CollectImageReadResultIds(widened); + ASSERT_EQ(readIds.size(), 1u); + const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front()); + ASSERT_NE(loadMask, nullptr) << "the imageLoad result is consumed unmasked"; + EXPECT_TRUE(HasComponents(*loadMask, {0u, 1u, 2u, 7u})); +} + +// ...and the same module through the emitter, which is where the failure actually showed: ESSL has +// no `r11f_g11f_b10f` token, SPIRV-Cross throws for it, and the throw took every image uniform +// declared in the same stage with it. +TEST(WidenImageFormats, PackedFloatImageOnlyReachesEsslThroughTheCarrier) { + const Vector spirv = CompileFragment(kR11fG11fB10fLoadStore); + ASSERT_FALSE(spirv.empty()); + + const EsslAttempt before = EmitEssl(spirv); + EXPECT_FALSE(before.succeeded) + << "SPIRV-Cross printed r11f_g11f_b10f for an ES target; the widening's premise has " + "changed:\n" + << before.text; + + Vector widened; + ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false, + /*enableSpirvValidation=*/true)); + const EsslAttempt after = EmitEssl(widened); + ASSERT_TRUE(after.succeeded) << after.error; + EXPECT_NE(after.text.find("rgba16f"), String::npos) << after.text; + EXPECT_EQ(after.text.find("r11f_g11f_b10f"), String::npos) << after.text; +} + TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccessesMasked) { const Vector spirv = CompileFragment(kR8uiLoadStore); ASSERT_FALSE(spirv.empty()); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp index 2b074fd5..174896a8 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp @@ -50,16 +50,40 @@ namespace MobileGL { constexpr uint32_t kImageAccessImageOperand = 0; constexpr uint32_t kImageWriteTexelOperand = 2; - // The exact carrier of a non-core image format: the core GLSL ES format with the - // SAME component type and the SAME per-channel width, differing only in channel - // count. `channels` is what the original format really has, which is what every - // access through the carrier is masked back to. + // The carrier of a non-core image format: a core GLSL ES format that represents + // every value the original can hold, WITHOUT LOSS. `channels` is what the original + // format really has, which is what every access through the carrier is masked back + // to. // - // Only formats that widen EXACTLY appear here. r11f_g11f_b10f, rgb10_a2, - // rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm and r16_snorm have no - // same-width core carrier - every candidate is either lossy or changes the numeric - // domain a sampler would read - and are deliberately absent, so they keep the - // honest "no GLSL ES spelling" diagnostic rather than a silent approximation. + // Almost every entry is a pure CHANNEL widening - same component type, same + // per-channel width, more channels (rg32f -> rgba32f) - and for those the carrier + // is bit-exact: the storage holds the identical encoding, only wider. + // + // r11f_g11f_b10f is the one entry that is not. It has no same-width core carrier, + // so it takes rgba16f, and the two encodings differ. What matters is that the + // carrier is still LOSSLESS: an 11-bit float is e5m6 and a 10-bit float is e5m5, + // while a half is s1e5m10 - the SAME 5-bit exponent with a strictly longer + // mantissa - so every value the packed format can represent has an exact half. + // Nothing an application stores is rounded away. + // + // What DOES change is the reverse direction: the carrier can hold values the + // packed format could not - negatives (11f and 10f are unsigned), and mantissa + // bits finer than the 6 and 5 the format quantises to - so a value written through + // the image and then SAMPLED comes back on half's grid rather than the packed + // format's. That is a strictly finer grid, never a lossy one, and it is measured + // against the alternative, which is not a more faithful quantisation but no + // program at all: `layout(r11f_g11f_b10f)` has no ESSL spelling, SPIRV-Cross + // throws for it, and the stage - with every other image uniform declared beside it + // - is lost (KHR-GL43.shader_image_load_store.basic-allFormats-*, which fail on + // this format alone, and multiple-uniforms, where one such declaration killed a + // program holding eight images). + // + // The remaining eight - rgb10_a2, rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, + // rg16_snorm and r16_snorm - stay absent, and for a stronger reason than + // quantisation: core ESSL has no 16-bit normalized format at all and no 10-bit + // one, so every candidate carrier for them either loses range or changes the + // component TYPE the texture presents. They keep the honest "no GLSL ES spelling" + // diagnostic rather than a silent approximation. struct ImageFormatWidening { spv::ImageFormat Carrier = spv::ImageFormat::Unknown; uint32_t Channels = 0; @@ -73,6 +97,9 @@ namespace MobileGL { case spv::ImageFormat::Rg32f: return {spv::ImageFormat::Rgba32f, 2}; case spv::ImageFormat::Rg16f: return {spv::ImageFormat::Rgba16f, 2}; case spv::ImageFormat::R16f: return {spv::ImageFormat::Rgba16f, 1}; + // Not a channel widening but a lossless re-encoding - see above. Three + // channels, so the fourth reads as the 1 GL defines for a format without one. + case spv::ImageFormat::R11fG11fB10f: return {spv::ImageFormat::Rgba16f, 3}; // Unsigned normalized. case spv::ImageFormat::Rg8: return {spv::ImageFormat::Rgba8, 2}; case spv::ImageFormat::R8: return {spv::ImageFormat::Rgba8, 1}; diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h index f5d7be0a..e684dc0f 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.h @@ -54,12 +54,23 @@ namespace MobileGL { // alone survives storage this shader never wrote (glTexStorage with no upload, whose // surplus channels are undefined). // - // The other NINE (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16, r16, - // rgba16_snorm, rg16_snorm, r16_snorm) have NO same-width core carrier and are - // deliberately NOT widened here: every carrier for them is either lossy or changes the - // numeric domain of the texture a `sampler2D` would read from it. They keep the honest - // "no GLSL ES spelling" diagnostic instead of silently changing an application's - // quantisation behaviour. + // r11f_g11f_b10f has no same-width core carrier either, and takes rgba16f anyway, + // because that carrier is still LOSSLESS: 11f is e5m6 and 10f is e5m5 against a half's + // s1e5m10 - the SAME 5-bit exponent with a strictly longer mantissa - so every value + // the packed format can hold has an exact half. Only the reverse direction differs + // (the carrier also holds negatives, which 11f and 10f cannot sign, and mantissa bits + // finer than the 6 and 5 they quantise to, so a value written through the image and + // then SAMPLED lands on half's grid rather than the packed format's). That is measured + // against the alternative, which is not a truer quantisation but no program at all: + // the SPIRV-Cross throw takes the whole stage, every image uniform declared beside it + // included. + // + // The other EIGHT (rgb10_a2, rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm, + // r16_snorm) are deliberately NOT widened here: core ESSL has no 16-bit normalized + // format at all and no 10-bit one, so every carrier for them either loses range or + // changes the component TYPE the texture a `sampler2D` would read presents. They keep + // the honest "no GLSL ES spelling" diagnostic instead of silently changing an + // application's numeric domain. // // MUST MOVE WITH THE OTHER TWO LAYERS. The widening is not a shader-local rewrite: the // ES texture behind the image has to be allocated in the carrier format too, and From 529d26f38fdc09c21407ffb4bce4dce020782fb0 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 21:27:14 -0400 Subject: [PATCH 2/6] [Fix] (DirectGLES): arm the image-format widening for r11f_g11f_b10f in the reflection gate too --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 25 +++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index f9fa4894..0a81fb6c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5150,12 +5150,19 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - // The GL internal format a glslang layout format names, for the seventeen non-core - // formats WidenImageFormatsForEssl carries exactly plus nothing else: the only + // The GL internal format a glslang layout format names, for the eighteen non-core + // formats WidenImageFormatsForEssl carries losslessly plus nothing else: the only // question asked of it is "does this DECLARED format widen", and answering 0 for // everything else is the same "no" a non-widenable format gets. Kept as its own // switch rather than routed through the frontend's enum converters because a // TLayoutFormat is a glslang value and the reflection snapshot stores it raw. + // + // IT MUST LIST EXACTLY WHAT WideningOfSpirvImageFormat DOES. This table is what arms + // the pass (ImageFormatWillBeWidened -> declaresWidenableImageFormat), so a format the + // pass would carry but this switch answers 0 for never gets the chance: the module + // reaches SPIRV-Cross with its original qualifier, the throw takes the stage, and the + // only visible symptom is the "no GLSL ES spelling" diagnostic for a format that has + // one. That is exactly what r11f_g11f_b10f did until it was added here. Uint GLInternalFormatOfLayoutFormat(glslang::TLayoutFormat format) { switch (format) { case glslang::ElfRg32f: return 0x8230; // GL_RG32F @@ -5175,6 +5182,10 @@ namespace MobileGL::MG_Backend::DirectGLES { case glslang::ElfR16ui: return 0x8234; // GL_R16UI case glslang::ElfRg8ui: return 0x8238; // GL_RG8UI case glslang::ElfR8ui: return 0x8232; // GL_R8UI + // Not a channel widening but a lossless re-encoding into rgba16f - the one entry + // here whose carrier has a different per-channel layout. See + // WidenImageFormatsPass.h. + case glslang::ElfR11fG11fB10f: return 0x8C3A; // GL_R11F_G11F_B10F default: return 0; } @@ -5235,11 +5246,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // spelling still has to become legal ESSL somehow. const auto declaredFormat = static_cast(type.layoutFormat); if (!IsCoreEsslLayoutFormat(declaredFormat)) { - // Seventeen of the twenty-six non-core formats are re-declared in the core - // format that carries them exactly, with every access masked back to the - // channels GL says they have (WidenImageFormatsForEssl, and the matching - // storage/bind widening in TextureImpl). Those need neither the extension - // nor the diagnostic: there IS a legal spelling for them now. + // Eighteen of the twenty-six non-core formats are re-declared in a core + // format that carries them losslessly, with every access masked back to + // the channels GL says they have (WidenImageFormatsForEssl, and the + // matching storage/bind widening in TextureImpl). Those need neither the + // extension nor the diagnostic: there IS a legal spelling for them now. if (ImageFormatWillBeWidened(GLInternalFormatOfLayoutFormat(declaredFormat))) { inputs.declaresWidenableImageFormat = true; } else { From f3cd4091bf6624faf3116c4e5dc4a574e07f390c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 21:38:45 -0400 Subject: [PATCH 3/6] [Fix, Test] (DirectGLES): decode the packed r11f_g11f_b10f shadow into the float level its rgba16f carrier is uploaded as --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 95 +++++++++++++++++-- MobileGL/MG_Backend/DirectGLES/Utils.cpp | 13 +++ MobileGL/MG_Backend/DirectGLES/Utils.h | 7 ++ .../Scenarios/NonCoreImageFormatScenario.cpp | 89 +++++++++++++++++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 0a81fb6c..06538896 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -29,7 +29,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -2706,21 +2708,98 @@ namespace MobileGL::MG_Backend::DirectGLES { widenedData, IsIntegerWidenableFormat(format)); } + // One channel of a packed r11f_g11f_b10f word as a float. The two 11-bit channels are + // e5m6 and the 10-bit one e5m5 - IEEE-shaped but UNSIGNED, so there is no sign bit to + // read and the exponent bias is the 15 a 5-bit exponent always carries. + static Float DecodePackedUnsignedFloat(Uint32 bits, Uint mantissaBits) { + const Uint32 mantissaScale = 1u << mantissaBits; + const Uint32 mantissa = bits & (mantissaScale - 1u); + const Uint32 exponent = bits >> mantissaBits; + if (exponent == 0u) { + // Subnormal, and zero with it: no implied leading 1, and the exponent is the + // smallest NORMAL one rather than the encoded 0. + return std::ldexp(static_cast(mantissa) / static_cast(mantissaScale), -14); + } + if (exponent == 31u) { + return mantissa == 0u ? std::numeric_limits::infinity() + : std::numeric_limits::quiet_NaN(); + } + return std::ldexp(1.0f + static_cast(mantissa) / static_cast(mantissaScale), + static_cast(exponent) - 15); + } + + // The r11f_g11f_b10f shadow decoded into the GL_RGBA / GL_FLOAT level its GL_RGBA16F + // carrier is uploaded as. Alpha is the 1 GL defines for a format that has no alpha + // channel, which is the same constant the shader-side mask writes, so a texel this + // function produced and a texel an imageStore produced are indistinguishable. + // + // Sized from the LEVEL, not the source, for the reason PrepareChannelWidenedUpload is: + // the driver reads a full width*height*depth*4 floats for the transfer it was handed. + static const void* PreparePackedFloatWidenedUpload(const IntVec3& texelSize, const void* data, + SizeT byteSize, Vector& widenedData) { + constexpr SizeT kSourceTexelBytes = sizeof(Uint32); + if (data == nullptr || byteSize < kSourceTexelBytes) { + return data; + } + const SizeT texelCount = static_cast(std::max(texelSize.x(), 0)) * + static_cast(std::max(texelSize.y(), 0)) * + static_cast(std::max(texelSize.z(), 1)); + if (texelCount == 0) { + return data; + } + const SizeT copyTexelCount = std::min(texelCount, byteSize / kSourceTexelBytes); + + widenedData.assign(texelCount * 4u * sizeof(Float), 0); + const auto* src = static_cast(data); + auto* dst = reinterpret_cast(widenedData.data()); + for (SizeT i = 0; i < texelCount; ++i, dst += 4) { + Float rgb[3] = {0.0f, 0.0f, 0.0f}; + if (i < copyTexelCount) { + Uint32 packed = 0; + // Through a memcpy rather than a Uint32 read of `src`: the shadow is a byte + // buffer with no alignment promise of its own. + Memcpy(&packed, src + i * kSourceTexelBytes, sizeof(packed)); + rgb[0] = DecodePackedUnsignedFloat(packed & 0x7FFu, 6u); + rgb[1] = DecodePackedUnsignedFloat((packed >> 11u) & 0x7FFu, 6u); + rgb[2] = DecodePackedUnsignedFloat((packed >> 22u) & 0x3FFu, 5u); + } + dst[0] = rgb[0]; + dst[1] = rgb[1]; + dst[2] = rgb[2]; + dst[3] = 1.0f; + } + return widenedData.data(); + } + // The transfer half of the image-format widening: an image-bindable texture whose ES // storage was widened to a core carrier is described to the driver as a four-component - // transfer, so its one- or two-component client data has to be repacked the same way the - // three-channel colour-renderable widening repacks its own. + // transfer, so its narrower client data has to be repacked the same way the three-channel + // colour-renderable widening repacks its own. + // + // Two shapes, because the carriers come in two kinds. Seventeen of the eighteen keep the + // frontend format's component TYPE and only add channels, so padding the shadow out to + // four components is the whole conversion. r11f_g11f_b10f does not: its shadow is one + // PACKED 32-bit word per texel and its carrier is GL_RGBA16F, so the word has to be + // DECODED into four floats. Reading it as three components of the carrier's type - what + // the repack below would do - would take twelve bytes from a four-byte texel and shear + // the level, which is what the allFormats LOAD walkers see and the STORE ones do not (a + // store overwrites every texel the upload got wrong). // // Composes with PrepareFallbackUpload rather than replacing it, and the composition is a - // no-op by construction: none of the seventeen widened formats is a three-channel one - // (GetWidenableClientComponentCount reports 0 for every one of them), and the SNORM - // shadow-to-float conversion only fires for a GL_FLOAT transfer type, which the widened - // triple never picks for the two SNORM8 formats. So the shadow reaches this untouched and - // one repack is all that runs. + // no-op by construction: none of the widened formats is one GetWidenableClientComponentCount + // reports a count for, and the SNORM shadow-to-float conversion only fires for a GL_FLOAT + // transfer type, which the widened triple never picks for the two SNORM8 formats. So the + // shadow reaches this untouched and one conversion is all that runs. static const void* PrepareImageWidenedUpload(const TextureImpl::ImageBindableStorageWidening& widening, const IntVec3& texelSize, const void* data, SizeT byteSize, Vector& widenedData) { - if (!widening || widening.SourceChannels == 0 || widening.SourceChannels >= 4) { + if (!widening || widening.SourceChannels == 0 || widening.SourceChannels > 4) { + return data; + } + if (widening.PackedFloatSource) { + return PreparePackedFloatWidenedUpload(texelSize, data, byteSize, widenedData); + } + if (widening.SourceChannels == 4) { return data; } return PrepareChannelWidenedUpload(widening.SourceChannels, texelSize, data, byteSize, widening.Type, diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index cfdadae7..2d3ba4c1 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -277,6 +277,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // its own; this call is only here to spell the transfer pair that describes it. MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags{}, nullptr, &widening.Format, &widening.Type); + // r11f_g11f_b10f is the one carrier that is not a channel widening, and the transfer + // pair has to say so. Every other entry keeps the frontend format's own component + // type - a GL_RG16F shadow is halves and so is its GL_RGBA16F carrier, so padding the + // channels is the whole conversion. This shadow is a PACKED 32-bit word (GL_RGB with + // GL_UNSIGNED_INT_10F_11F_11F_REV, TextureFormatProcessor::NormalizePixelFormat), and + // no ES driver accepts that type for a GL_RGBA16F level. GL_FLOAT is asked for + // instead - legal for GL_RGBA16F, and the type the unpack in + // PrepareImageWidenedUpload writes - so the two sides name the same layout. + if (internalFormat == TextureInternalFormat::R11FG11FB10F) { + widening.Format = GL_RGBA; + widening.Type = GL_FLOAT; + widening.PackedFloatSource = true; + } return widening; } } // namespace TextureImpl diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index 8d9d0dcc..b062671c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -113,6 +113,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // transfer type cannot tell the two apart (GL_UNSIGNED_BYTE serves both RG8 and // RG8UI), so the carrier decides. Bool IntegerData = false; + // The frontend shadow is a PACKED word rather than SourceChannels separate components + // of the carrier's own type, so the upload has to DECODE it instead of padding it out + // (PrepareImageWidenedUpload). True only for r11f_g11f_b10f, whose shadow is one + // GL_UNSIGNED_INT_10F_11F_11F_REV per texel and whose carrier is GL_RGBA16F: the + // channel repack every other entry uses would read three floats out of a four-byte + // texel and shear the level. + Bool PackedFloatSource = false; explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; } }; diff --git a/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp index 60f45bb6..53e52209 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.cpp @@ -262,6 +262,95 @@ void main() } } + // GL_R11F_G11F_B10F, the format the allFormats and allTargets walkers stop at once the + // channel widening has carried everything before it - and the one carrier that is NOT a + // channel widening. It has no core format of its own per-channel width, so it is carried + // in GL_RGBA16F, whose 5-bit exponent and longer mantissa hold every 11f (e5m6) and 10f + // (e5m5) value exactly. + // + // What makes this case different from every other one here, and why it is worth its own + // test: the frontend's shadow for this format is ONE PACKED 32-BIT WORD per texel, not + // three components of the carrier's type. The upload therefore has to DECODE it, where + // every other widening only pads channels onto data already in the right component type. + // A widening that reused the channel repack reads three floats out of a four-byte texel + // and shears the whole level - which a STORE test cannot see, because the dispatch + // overwrites every texel the upload got wrong. So the seed here is per-texel distinct and + // is checked through an imageLoad BEFORE anything is stored. + // + // Every constant is chosen to be exact in both encodings, so the comparisons can be + // equality rather than tolerance: the 1/8 steps need three mantissa bits of the 11f + // channels' six, and the 1/16 steps at exponent 1 need one of the 10f channel's five. + TEST_F(NonCoreImageFormatScenario, PackedFloatImageDecodesItsUploadAndDropsSurplusStores) { + if (!Ready()) GTEST_SKIP() << "no GL context"; + if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver"; + + constexpr int kTexels = kExtent * kExtent; + std::vector seed(static_cast(kTexels) * 3u, 0.0f); + for (int texel = 0; texel < kTexels; ++texel) { + seed[texel * 3 + 0] = 1.0f + static_cast(texel) / 8.0f; + seed[texel * 3 + 1] = 2.0f + static_cast(texel) / 8.0f; + seed[texel * 3 + 2] = 3.0f + static_cast(texel) / 16.0f; + } + const std::vector wideSeed(static_cast(kTexels) * 4u, -1.0f); + const GLuint narrow = MakeTexture(GL_R11F_G11F_B10F, GL_RGB, GL_FLOAT, seed.data()); + const GLuint wide = MakeTexture(GL_RGBA32F, GL_RGBA, GL_FLOAT, wideSeed.data()); + if (narrow == 0 || wide == 0) return; + + const GLuint loadProgram = MakeComputeProgram(R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +layout (r11f_g11f_b10f, binding = 0) readonly uniform image2D narrow; +layout (rgba32f, binding = 1) writeonly uniform image2D wide; + +void main() +{ + ivec2 coord = ivec2(gl_GlobalInvocationID.xy); + imageStore(wide, coord, imageLoad(narrow, coord)); +} +)"); + const GLuint storeProgram = MakeComputeProgram(R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +layout (r11f_g11f_b10f, binding = 0) writeonly uniform image2D narrow; + +void main() +{ + imageStore(narrow, ivec2(gl_GlobalInvocationID.xy), vec4(5.0, 6.0, 7.0, 8.0)); +} +)"); + if (loadProgram == 0 || storeProgram == 0) return; + + // THE UPLOAD, read back through the image. A sheared decode still produces plausible + // floats, so the check is per texel and the seed never repeats a value. + BindImage(kNarrowUnit, narrow, GL_R11F_G11F_B10F, GL_READ_ONLY); + BindImage(kWideUnit, wide, GL_RGBA32F, GL_WRITE_ONLY); + Dispatch(loadProgram); + + const std::vector loaded = ReadFloats(wide, GL_RGBA, 4); + for (int texel = 0; texel < kTexels; ++texel) { + EXPECT_FLOAT_EQ(loaded[texel * 4 + 0], seed[texel * 3 + 0]) << "texel " << texel << " red"; + EXPECT_FLOAT_EQ(loaded[texel * 4 + 1], seed[texel * 3 + 1]) << "texel " << texel << " green"; + EXPECT_FLOAT_EQ(loaded[texel * 4 + 2], seed[texel * 3 + 2]) << "texel " << texel << " blue"; + EXPECT_FLOAT_EQ(loaded[texel * 4 + 3], 1.0f) + << "texel " << texel << ": imageLoad on a format without alpha must report 1"; + } + + // THE STORE. Three channels survive and the fourth is dropped, which is the mask this + // format needs and no other widened format does - every other carrier here pins two + // or three of the carrier's channels, this one pins only alpha. + BindImage(kNarrowUnit, narrow, GL_R11F_G11F_B10F, GL_WRITE_ONLY); + Dispatch(storeProgram); + + const std::vector stored = ReadFloats(narrow, GL_RGB, 3); + for (int texel = 0; texel < kTexels; ++texel) { + EXPECT_FLOAT_EQ(stored[texel * 3 + 0], 5.0f) << "texel " << texel << " red"; + EXPECT_FLOAT_EQ(stored[texel * 3 + 1], 6.0f) << "texel " << texel << " green"; + EXPECT_FLOAT_EQ(stored[texel * 3 + 2], 7.0f) << "texel " << texel << " blue"; + } + } + // GL_R8UI: the only format KHR-GL43.shader_image_load_store.single-byte_data_alignment // declares, and one SPIRV-Cross refuses to print for ESSL at all, so before the emulation // no text was produced for the stage and the dispatch could not run. From e18bac8cb2dbabb75c19f53ed969ef7a4b11afda Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 22:02:23 -0400 Subject: [PATCH 4/6] [Fix, Test] (ShaderTranspiler, DirectGLES): never widen a buffer image - its texels are the application buffer, not storage we can reallocate --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 9 +++- .../WidenImageFormatsTest.cpp | 45 +++++++++++++++++++ .../SpirvPasses/WidenImageFormatsPass.cpp | 19 ++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index c3cc734c..245786a0 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1482,8 +1482,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // a bind format that names a class the storage does not have is left alone: GL // already calls that undefined, and inventing a carrier for it would only make the // out-of-class read wider. + // + // A BUFFER texture is excluded on both sides: it has no storage of its own to widen + // (its texels are the application's buffer object), so WidenImageFormatsPass declines + // every buffer image and the bind must decline with it, or the driver would be handed + // a carrier the shader never addressed. See the Dim::Buffer guard there for the + // 32-byte GL_RG32F measurement that pinned it. GLenum bindFormat = imageBinding.Format; - if (TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) { + if (imageBinding.Texture->GetTarget() != TextureTarget::TextureBuffer && + TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) { const auto boundFormatWidening = TextureImpl::GetImageBindableStorageWidening( MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format)); if (boundFormatWidening) { diff --git a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp index e73c346d..efc85822 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.cpp @@ -241,6 +241,18 @@ void main() { imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0)); fragColor = texel; } +)"; + + // rg32f again, but as a BUFFER image. Same format, same carrier on paper - and it must be + // left alone anyway, because a buffer image's texels are the application's buffer object. + const char* const kRg32fBufferLoadStore = R"(#version 430 core +layout(rg32f, binding = 0) uniform imageBuffer img; +out vec4 fragColor; +void main() { + vec4 texel = imageLoad(img, int(gl_FragCoord.x)); + imageStore(img, int(gl_FragCoord.x), vec4(1.0, 2.0, 3.0, 4.0)); + fragColor = texel; +} )"; // rg16 is one of the EIGHT with no core carrier at all - core ESSL has no 16-bit normalized @@ -439,6 +451,39 @@ TEST(WidenImageFormats, PackedFloatImageOnlyReachesEsslThroughTheCarrier) { EXPECT_EQ(after.text.find("r11f_g11f_b10f"), String::npos) << after.text; } +// A BUFFER image is declined whatever its format, and the format alone cannot say so - rg32f is +// carried exactly when it is an image2D. What makes the difference is that widening REALLOCATES +// the texture behind the image in the carrier, and a buffer image has no texture storage to +// reallocate: its texels are the application's buffer object, usually also a vertex, index or +// storage buffer. Widening one leaves the shader striding 16 bytes through 8-byte texels - the +// measured symptom on an Adreno 830 was a 32-byte GL_RG32F buffer reading back +// [1,100] [0,1] [2,100] [0,1] instead of [1,100] [2,100] [3,100] [4,100], with the last two texels +// written past the end of the application's buffer. +TEST(WidenImageFormats, BufferImagesAreDeclinedEvenWhenTheirFormatHasACarrier) { + const Vector spirv = CompileFragment(kRg32fBufferLoadStore); + ASSERT_FALSE(spirv.empty()); + + const auto types = CollectStorageImageTypes(spirv); + ASSERT_EQ(types.size(), 1u); + EXPECT_EQ(types.front().format, static_cast(spv::ImageFormat::Rg32f)) + << "the fixture stopped declaring the format this test is about"; + + // The gate says no, so the optimizer is never even run for it... + EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv)); + // ...and running it anyway changes nothing, which is what keeps the gate and the pass from + // disagreeing about a module. + Vector widened; + ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false, + /*enableSpirvValidation=*/true); + EXPECT_TRUE(widened.empty() || widened == spirv) << "a buffer image was rewritten"; + + // The same format in a NON-buffer image still widens, or this test would pass for the wrong + // reason - a widening that had simply stopped working. + const Vector planar = CompileFragment(kRg32fLoadStore); + ASSERT_FALSE(planar.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(planar)); +} + TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccessesMasked) { const Vector spirv = CompileFragment(kR8uiLoadStore); ASSERT_FALSE(spirv.empty()); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp index 174896a8..eecdcbed 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp @@ -39,6 +39,7 @@ namespace MobileGL { // OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS, // 5 Sampled, 6 Format. constexpr uint32_t kImageSampledTypeOperand = 0; + constexpr uint32_t kImageDimOperand = 1; constexpr uint32_t kImageSampledOperand = 5; constexpr uint32_t kImageFormatOperand = 6; // A storage image, i.e. one reached through imageLoad/imageStore rather than a @@ -246,6 +247,24 @@ namespace MobileGL { bool onlyFormatsSpirvCrossRefusesToPrint) { if (type == nullptr || type->opcode() != spv::Op::OpTypeImage) return false; if (type->GetSingleWordInOperand(kImageSampledOperand) != kSampledStorageImage) return false; + // A BUFFER image is never widened, whatever its format. Widening works because + // the ES texture behind the image can be REALLOCATED in the carrier, so the + // texel the shader addresses and the texel the storage holds stay the same + // size. A buffer image has no storage of its own to reallocate: its texels are + // the application's buffer object, at the size and layout the application gave + // it, and that buffer is usually also a vertex, index or storage buffer whose + // contents are not ours to relayout. + // + // Widening one anyway makes the shader stride 16 bytes through 8-byte texels. + // Measured on an Adreno 830 with a 32-byte GL_RG32F buffer and a shader storing + // (i+1, 100) at texel i: the readback came back [1,100] [0,1] [2,100] [0,1] - + // texels 0 and 1 landed on top of all four, texels 2 and 3 ran off the end of + // the application's buffer. Declining leaves the honest "no GLSL ES spelling" + // failure instead, which loses the same stage but corrupts nothing. + if (static_cast(type->GetSingleWordInOperand(kImageDimOperand)) == + spv::Dim::Buffer) { + return false; + } const auto format = static_cast(type->GetSingleWordInOperand(kImageFormatOperand)); if (!WideningOfSpirvImageFormat(format)) return false; From 908172ba0f717f2482692398fe227620cb77eceb Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 22:08:25 -0400 Subject: [PATCH 5/6] [Feat] (DirectGLES, Config, ShaderTranspiler): route gl_ViewportIndex on Espryt by replaying a draw per distinct viewport state --- MobileGL/Config.h | 13 + MobileGL/ConfigLoader.cpp | 2 + MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 282 +++++++++++++++--- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 255 +++++++++++++++- MobileGL/MG_Backend/DirectGLES/Managers.h | 74 +++++ MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp | 26 +- .../ShaderTranspiler/TranslationCache.cpp | 2 +- .../ShaderTranspiler/TranslationCache.h | 12 +- 8 files changed, 612 insertions(+), 54 deletions(-) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 1aebbe06..4ee37b48 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -212,6 +212,19 @@ namespace MobileGL::MG_Config { // miscompiled shader: if a device ever renders differently with the cache // on, one run with this falsy says so. QuirkOverride ShaderTranslationCache = QuirkOverride::Auto; + // MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION: DirectGLES' gl_ViewportIndex routing + // emulation - the builtin becomes a flat varying, the fragment stage gets a + // per-pass gate, and a routed draw is REPLAYED once per distinct viewport state + // with the real glViewport/glScissor/glDepthRangef set for it. Auto is ON, and + // it is ON even where the driver advertises GL_OES_viewport_array, because that + // extension only ever gave the SHADER a compilable name: MobileGL has never + // programmed a driver's INDEXED viewport state (SyncRenderState pushes index 0 + // and nothing else), so on an extension-capable driver every index rasterized as + // index 0 exactly as it did without one. ForceOff returns to that behaviour - + // the pre-emulation path, extension passthrough where it exists and + // LowerViewportIndexPass' demote-to-a-plain-global where it does not - and is + // the negative control the emulation is measured against. + QuirkOverride ViewportArrayEmulation = QuirkOverride::Auto; }; extern FeaturesTable Features; } // namespace MobileGL::MG_Config diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index cb4f467b..c86a47ab 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -195,6 +195,8 @@ namespace MobileGL::MG_ConfigLoader { features.AsyncOptimisticShaderStatus = QueryEnvQuirkOverride("MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS"); features.ShaderTranslationCache = QueryEnvQuirkOverride("MOBILEGL_SHADER_CACHE"); + features.ViewportArrayEmulation = + QueryEnvQuirkOverride("MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION"); } inline void InitBackendType() { diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index c3cc734c..1536e905 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -3260,6 +3260,188 @@ namespace MobileGL::MG_Backend::DirectGLES { return program->ReadsDrawID() || (batchCarriesBaseVertices && program->ReadsBaseVertex()); } + // ---- gl_ViewportIndex routing emulation, draw half --------------------------------------- + // See the block comment in Managers.h for what this is and why. Here is the state half: one + // replay pass per DISTINCT viewport state, each pushing that state onto the ES context's one + // viewport / one scissor / one depth range and telling the fragment gate which indices it + // serves. + namespace ViewportRoutingImpl { + // One replay pass: the state to push, and the set of gl_ViewportIndex values whose + // fragments this pass is allowed to keep. + struct RoutingPass { + IntVec4 viewport{}; + IntVec4 scissorBox{}; + FloatVec2 depthRange{}; + Bool scissorTest = false; + Uint32 indexMask = 0; + }; + + static constexpr Uint32 kAllViewportsMask = + (RenderStateParameters::MAX_VIEWPORTS >= 32) + ? 0xFFFFFFFFu + : ((1u << RenderStateParameters::MAX_VIEWPORTS) - 1u); + + // The plan for the draw currently being issued. A file-scope buffer rather than a return + // value because Begin/Apply/End are three calls around a draw the caller writes, and a + // fixed array of 16 keeps it allocation-free on a path that is per draw. NOT re-entrant, + // which is a property of the call sites and not an accident: every wrap in this file and + // in MultiDraw.cpp is around the innermost native glDraw*, so no replay can begin inside + // another - and a multi-draw tier that replayed its whole loop would be nesting. + static Array g_passes{}; + static Uint g_passCount = 0; + static PrgramImpl::BackendProgramObjectImpl* g_routedProgram = nullptr; + + // What index `i` actually rasterizes against, resolved exactly the way SyncRenderState + // resolves index 0 - including both substitutions it makes, which are not cosmetic: + // + // * a viewport of zero extent means "the application has never called glViewport", and + // GL's initial viewport is the whole surface, which the frontend cannot spell before + // a surface exists; + // * a scissor rectangle is read through the WRITTEN flag and not through its extent, + // because glScissor(0, 0, 0, 0) is a legal request meaning "reject every fragment" + // and is byte-identical to the never-written default that means the opposite. + // + // Resolving them here rather than deferring to SyncRenderState is what makes the grouping + // below correct: two indices that differ only in a field that resolves to the same + // rectangle really do rasterize identically and must share one pass. + static RoutingPass ResolveIndexState(const RenderStateParameters& parameters, Uint index, + Int surfaceWidth, Int surfaceHeight) { + RoutingPass pass; + const FloatVec4& viewport = parameters.Viewports[index]; + pass.viewport = IntVec4(static_cast(std::lround(viewport.x())), + static_cast(std::lround(viewport.y())), + static_cast(std::lround(viewport.z())), + static_cast(std::lround(viewport.w()))); + if ((pass.viewport.z() <= 0 || pass.viewport.w() <= 0) && surfaceWidth > 0 && surfaceHeight > 0) { + pass.viewport = IntVec4(0, 0, surfaceWidth, surfaceHeight); + } + pass.scissorBox = parameters.ScissorBoxes[index]; + if ((parameters.ScissorBoxWrittenMask & (1u << index)) == 0 && surfaceWidth > 0 && + surfaceHeight > 0) { + pass.scissorBox = IntVec4(0, 0, surfaceWidth, surfaceHeight); + } + pass.depthRange = parameters.DepthRanges[index]; + pass.scissorTest = (parameters.ScissorTestEnabledMask & (1u << index)) != 0; + return pass; + } + + static Bool SameState(const RoutingPass& a, const RoutingPass& b) { + return a.viewport == b.viewport && a.scissorBox == b.scissorBox && + a.depthRange == b.depthRange && a.scissorTest == b.scissorTest; + } + } // namespace ViewportRoutingImpl + + Uint BeginViewportRoutingPasses() { + using namespace ViewportRoutingImpl; + g_passCount = 1; + g_routedProgram = nullptr; + + // The whole emulation behind one static load, for every application that has never built + // a program writing gl_ViewportIndex - which is all of them but the conformance suite. + // Without it every draw in the process would pay GetCurrentBackendProgram's chain of + // frontend lookups for an answer that cannot change. + if (!g_anyProgramRoutesViewportIndex) { + return 1; + } + + auto* program = GetCurrentBackendProgram(); + if (program == nullptr || !program->RoutesViewportIndex()) { + return 1; + } + g_routedProgram = program; + // The gate reads zero until something writes it, and a zero mask discards every fragment. + // So this is not an optimization that can be skipped in the one-pass case - it is what + // keeps a routing program drawing at all. + program->SetViewportPassMask(kAllViewportsMask); + + // Replaying multiplies every side effect the vertex and geometry stages have, and the + // fragment gate can only undo the ones that happen in the FRAGMENT stage. Transform + // feedback records per emitted primitive, so a replayed draw would write its vertices N + // times; rasterizer discard means there are no fragments to gate at all, so replaying + // would be pure cost with nothing to show for it. Both fall back to a single pass with an + // open gate, i.e. to the pre-emulation behaviour, rather than to wrong data. + if (MG_State::pGLContext->IsTransformFeedbackActive() || + MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + return 1; + } + + const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + Int surfaceWidth = 0; + Int surfaceHeight = 0; + if (!QueryCurrentSurfaceSize(surfaceWidth, surfaceHeight)) { + surfaceWidth = 0; + surfaceHeight = 0; + } + + Uint count = 0; + for (Uint index = 0; index < RenderStateParameters::MAX_VIEWPORTS; ++index) { + const RoutingPass resolved = + ResolveIndexState(parameters, index, surfaceWidth, surfaceHeight); + Uint existing = 0; + for (; existing < count; ++existing) { + if (SameState(g_passes[existing], resolved)) break; + } + if (existing == count) { + g_passes[count] = resolved; + ++count; + } + g_passes[existing].indexMask |= (1u << index); + } + + // One group is the overwhelmingly common case - it is what glViewport, glScissor and + // glDepthRange leave behind, because ARB_viewport_array defines all three as writing + // EVERY index. The mask is already open and index 0's state is what SyncRenderState + // pushed, so there is nothing to replay and nothing to restore. + if (count <= 1) { + g_passCount = 1; + return 1; + } + g_passCount = count; + return count; + } + + void ApplyViewportRoutingPass(Uint pass) { + using namespace ViewportRoutingImpl; + if (pass >= g_passCount || g_routedProgram == nullptr) { + return; + } + const RoutingPass& entry = g_passes[pass]; + g_GLESFuncs.glViewport(entry.viewport.x(), entry.viewport.y(), entry.viewport.z(), + entry.viewport.w()); + g_GLESFuncs.glScissor(entry.scissorBox.x(), entry.scissorBox.y(), entry.scissorBox.z(), + entry.scissorBox.w()); + // ES has one scissor-test enable where GL has sixteen, so the per-index bit becomes a + // per-pass glEnable/glDisable. This is the half DirectVulkan cannot do at all (Vulkan has + // no per-viewport scissor toggle either and has to widen a disabled index's rectangle to + // the whole framebuffer instead); here the rectangle stays honest. + entry.scissorTest ? g_GLESFuncs.glEnable(GL_SCISSOR_TEST) : g_GLESFuncs.glDisable(GL_SCISSOR_TEST); + g_GLESFuncs.glDepthRangef(entry.depthRange.x(), entry.depthRange.y()); + g_routedProgram->SetViewportPassMask(entry.indexMask); + } + + void EndViewportRoutingPasses(Uint passCount) { + using namespace ViewportRoutingImpl; + if (passCount <= 1) { + // Nothing was pushed and the mask is already open; leaving the shadow alone here is + // what keeps a non-routing draw at exactly its previous cost. + g_routedProgram = nullptr; + return; + } + if (g_routedProgram != nullptr) { + // Any draw that reaches the driver without going through a replay - an internal blit, + // or a path this emulation has not been taught about - must not inherit the last + // pass's mask and paint nothing. + g_routedProgram->SetViewportPassMask(kAllViewportsMask); + } + g_routedProgram = nullptr; + g_passCount = 0; + // The viewport, scissor, scissor-test enable and depth range now on the ES context belong + // to the last replay pass, and the shadow SyncRenderState diffs against does not know it. + // A full resync is the honest repair and costs one state push on the next draw, which + // only a viewport-routing workload ever pays. + RenderStateImpl::InvalidateSyncedRenderState(); + } + static Bool SupportsNativeIndirectDraws() { return g_GLESCapabilities.SupportsDrawIndirect; } @@ -3317,7 +3499,9 @@ namespace MobileGL::MG_Backend::DirectGLES { SetCurrentBaseInstance(cmd.baseInstance); SetCurrentBaseVertex(cmd.baseVertex); } - g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast(cmdByteOffset)); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast(cmdByteOffset)); + }); } } else { for (GLsizei i = 0; i < drawcount; ++i) { @@ -3330,9 +3514,11 @@ namespace MobileGL::MG_Backend::DirectGLES { SetCurrentBaseInstance(cmd.baseInstance); SetCurrentBaseVertex(cmd.baseVertex); const auto indexByteOffset = static_cast(cmd.firstIndex) * indexSize; - g_GLESFuncs.glDrawElementsInstancedBaseVertex( - mode, static_cast(cmd.count), type, reinterpret_cast(indexByteOffset), - static_cast(cmd.instanceCount), cmd.baseVertex); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsInstancedBaseVertex( + mode, static_cast(cmd.count), type, reinterpret_cast(indexByteOffset), + static_cast(cmd.instanceCount), cmd.baseVertex); + }); } } SetCurrentDrawID(0); @@ -3371,7 +3557,9 @@ namespace MobileGL::MG_Backend::DirectGLES { std::memcpy(&cmd, commandBytes + static_cast(i) * stride, sizeof(cmd)); SetCurrentBaseInstance(cmd.baseInstance); } - g_GLESFuncs.glDrawArraysIndirect(mode, reinterpret_cast(cmdByteOffset)); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawArraysIndirect(mode, reinterpret_cast(cmdByteOffset)); + }); } } else { for (GLsizei i = 0; i < drawcount; ++i) { @@ -3382,9 +3570,11 @@ namespace MobileGL::MG_Backend::DirectGLES { } SetCurrentDrawID(static_cast(i)); SetCurrentBaseInstance(cmd.baseInstance); - g_GLESFuncs.glDrawArraysInstanced(mode, static_cast(cmd.first), - static_cast(cmd.count), - static_cast(cmd.instanceCount)); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawArraysInstanced(mode, static_cast(cmd.first), + static_cast(cmd.count), + static_cast(cmd.instanceCount)); + }); } } SetCurrentDrawID(0); @@ -3610,7 +3800,9 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer; PrepareForDraw(syncBit); CheckPrimitiveRestartSupported(type); - g_GLESFuncs.glDrawElements(mode, count, type, indices); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElements(mode, count, type, indices); + }); } void DrawArrays(GLenum mode, GLint first, GLsizei count) { @@ -3626,7 +3818,9 @@ namespace MobileGL::MG_Backend::DirectGLES { (*backendVAOSlot)->SyncClientSideAttributesForDrawArrays(currentVAO, first, count); } } - g_GLESFuncs.glDrawArrays(mode, first, count); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawArrays(mode, first, count); + }); } void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) { @@ -3637,7 +3831,9 @@ namespace MobileGL::MG_Backend::DirectGLES { PrepareForDraw(syncBit); CheckPrimitiveRestartSupported(type); SetCurrentBaseVertex(basevertex); - g_GLESFuncs.glDrawElementsBaseVertex(mode, count, type, indices, basevertex); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsBaseVertex(mode, count, type, indices, basevertex); + }); SetCurrentBaseVertex(0); } @@ -3663,7 +3859,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } } if (feedDrawID) SetCurrentDrawID(static_cast(i)); - g_GLESFuncs.glDrawArrays(mode, first[i], count[i]); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawArrays(mode, first[i], count[i]); + }); } if (feedDrawID) SetCurrentDrawID(0); } @@ -3900,14 +4098,18 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer; PrepareForDraw(syncBit); SetCurrentBaseVertex(basevertex); - g_GLESFuncs.glDrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex); + }); SetCurrentBaseVertex(0); } void DrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void* indices) { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer; PrepareForDraw(syncBit); - g_GLESFuncs.glDrawRangeElements(mode, start, end, count, type, indices); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawRangeElements(mode, start, end, count, type, indices); + }); } // True when the driver will apply baseInstance to the vertex fetch itself, in which case the @@ -3930,12 +4132,14 @@ namespace MobileGL::MG_Backend::DirectGLES { PrepareForDraw(syncBit); SetCurrentBaseInstance(baseinstance); SetCurrentBaseVertex(basevertex); - if (UseNativeBaseInstance()) { - g_GLESFuncs.glDrawElementsInstancedBaseVertexBaseInstanceEXT(mode, count, type, indices, instancecount, - basevertex, baseinstance); - } else { - g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); - } + ForEachViewportRoutingPass([&] { + if (UseNativeBaseInstance()) { + g_GLESFuncs.glDrawElementsInstancedBaseVertexBaseInstanceEXT(mode, count, type, indices, instancecount, + basevertex, baseinstance); + } else { + g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); + } + }); SetCurrentBaseVertex(0); SetCurrentBaseInstance(0); } @@ -3945,7 +4149,9 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing; PrepareForDraw(syncBit); SetCurrentBaseVertex(basevertex); - g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); + }); SetCurrentBaseVertex(0); } @@ -3955,19 +4161,23 @@ namespace MobileGL::MG_Backend::DirectGLES { const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance)); PrepareForDraw(syncBit); SetCurrentBaseInstance(baseinstance); - if (UseNativeBaseInstance()) { - g_GLESFuncs.glDrawElementsInstancedBaseInstanceEXT(mode, count, type, indices, instancecount, - baseinstance); - } else { - g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount); - } + ForEachViewportRoutingPass([&] { + if (UseNativeBaseInstance()) { + g_GLESFuncs.glDrawElementsInstancedBaseInstanceEXT(mode, count, type, indices, instancecount, + baseinstance); + } else { + g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount); + } + }); SetCurrentBaseInstance(0); } void DrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount) { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing; PrepareForDraw(syncBit); - g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsInstanced(mode, count, type, indices, instancecount); + }); } void DrawElementsIndirect(GLenum mode, GLenum type, const void* indirect) { @@ -3999,18 +4209,22 @@ namespace MobileGL::MG_Backend::DirectGLES { const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance)); PrepareForDraw(syncBit); SetCurrentBaseInstance(baseinstance); - if (UseNativeBaseInstance()) { - g_GLESFuncs.glDrawArraysInstancedBaseInstanceEXT(mode, first, count, instancecount, baseinstance); - } else { - g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount); - } + ForEachViewportRoutingPass([&] { + if (UseNativeBaseInstance()) { + g_GLESFuncs.glDrawArraysInstancedBaseInstanceEXT(mode, first, count, instancecount, baseinstance); + } else { + g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount); + } + }); SetCurrentBaseInstance(0); } void DrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instancecount) { DrawSyncFlags syncBit = DrawSyncBit::Instancing; PrepareForDraw(syncBit); - g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawArraysInstanced(mode, first, count, instancecount); + }); } void DrawArraysIndirect(GLenum mode, const void* indirect) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index f9fa4894..e6432812 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -47,6 +47,16 @@ namespace MobileGL::MG_Backend::DirectGLES { constexpr const char* INDIRECT_PARAMS_BLOCK_NAME = "mg_IndirectParams"; constexpr const char* ZERO_BASED_INSTANCE_ID_NAME = "mg_ZeroBasedInstanceID"; + // See the block comment on ForEachViewportRoutingPass in Managers.h. Auto is ON, including on + // a driver that advertises GL_OES_viewport_array: that extension gives the shader a name, not + // the driver fifteen more rectangles to rasterize against, and nothing in MobileGL has ever + // programmed the indexed state it would need. + Bool ViewportArrayEmulationEnabled() { + return MG_Config::Features.ViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff; + } + + Bool g_anyProgramRoutesViewportIndex = false; + // ES has no atomic-counter buffers: glslang lowers every atomic_uint onto a synthesized // storage block, so one GL counter BUFFER costs one of the driver's shader-storage binding // points. Those slots are taken from the TOP of the range downwards - below the one @@ -300,6 +310,126 @@ namespace MobileGL::MG_Backend::DirectGLES { return source; } + // ---- gl_ViewportIndex routing emulation, ESSL half --------------------------------------- + // + // LowerViewportIndexPass has already turned the BuiltIn ViewportIndex OUTPUT into a plain + // Private global, so SPIRV-Cross printed `int mg_ViewportIndex;` at file scope and the stage + // still stores the index the application asked for - it just goes nowhere. The two passes + // below give it somewhere to go WITHOUT naming a builtin the language does not have: the + // producing stage's global becomes an ordinary flat varying, and the fragment stage gets a + // gate that discards every fragment whose primitive routed to a viewport the current replay + // pass is not drawing. DirectGLES.cpp's ForEachViewportRoutingPass is the other half - it + // re-issues the draw once per distinct viewport state with the real + // glViewport/glScissor/glDepthRangef pushed for it and this uniform set to the set of + // indices that state serves. + // + // FLAT is semantics, not performance: GL takes a primitive's viewport index from its + // PROVOKING VERTEX, which is exactly what flat interpolation delivers, so a primitive whose + // vertices carry different indices routes the way the spec says with no extra machinery. + // + // NO layout(location = N) on either side, deliberately. The two stages are transpiled + // independently and neither can see the other's location assignment: the producing stage + // knows its own outputs, the fragment stage only the subset it consumes, and a number derived + // from either can disagree with the other. Leaving both unqualified hands the assignment to + // the driver's linker, which then matches them BY NAME - the ordinary GLSL rule, and the only + // one that needs no cross-stage channel. The cost is one varying slot, which a program + // already at GL_MAX_VARYING_VECTORS cannot spare. + constexpr const char* VIEWPORT_INDEX_VARYING_NAME = "mg_ViewportIndex"; + constexpr const char* VIEWPORT_PASS_MASK_UNIFORM_NAME = "mg_ViewportPassMask"; + constexpr const char* VIEWPORT_GATED_ENTRY_POINT_NAME = "mg_ViewportGatedMain"; + constexpr const char* ESSL_ENTRY_POINT_SIGNATURE = "void main()"; + static_assert(RenderStateParameters::MAX_VIEWPORTS == 16, + "the fragment gate below spells the index clamp as `& 15` and the pass mask as a " + "16-bit int; both follow MAX_VIEWPORTS and have to be respelled with it"); + + // Producing stage (vertex / tessellation evaluation / geometry - the three GL lets write the + // builtin). Returns whether the demoted global was found and promoted, which is also the + // answer to "does this program route viewports at all". + Bool PromoteViewportIndexGlobalToVarying(String& source) { + // The same shape PromoteDrawParameterGlobalsToUniforms matches, and for the same reason: + // SPIRV-Cross prints the demoted global with or without a precision qualifier depending + // on what the module carried. Only a declaration that starts its own line may be + // rewritten - `mg_ViewportIndex = gl_InvocationID;` in the body contains the name too and + // has to be left exactly as it is. + const String declared = String(VIEWPORT_INDEX_VARYING_NAME) + ";"; + for (const char* declPrefix : {"highp int ", "mediump int ", "lowp int ", "int "}) { + const String declaration = String(declPrefix) + declared; + const SizeT pos = source.find(declaration); + if (pos == String::npos) { + continue; + } + // Column 0 of its own line is what separates the declaration from the tail of any + // other declaration or expression that ends in the same name. + if (pos != 0 && source[pos - 1] != '\n') { + continue; + } + source.replace(pos, declaration.size(), + String("flat out highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";"); + return true; + } + return false; + } + + // Fragment stage. Returns false when the stage has no entry point to gate onto, which the + // caller reports: the program still links and still renders, it just renders every index + // with the first replay pass's state - i.e. it degrades to the pre-emulation behaviour + // rather than to a black screen. + Bool InjectViewportIndexPassGate(String& source) { + // Built beside the input and swapped in only on success, so a stage this pass declines + // reaches the driver exactly as it arrived rather than half-rewritten. + // A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either - + // LowerViewportIndexPass deliberately demotes only OUTPUTS, because a demoted INPUT would + // answer from an undefined Private global. Now that the routing varying exists and + // carries the real per-primitive value, that read has somewhere honest to go. + String gated = ReplaceIdentifier(source, "gl_ViewportIndex", VIEWPORT_INDEX_VARYING_NAME); + + const SizeT entryPos = gated.find(ESSL_ENTRY_POINT_SIGNATURE); + if (entryPos == String::npos) { + return false; + } + + // Declarations go immediately before the entry point rather than after #version: that + // position is already past every #extension directive (which must precede any other + // token) and past everything the body can name, so it can invalidate neither. + // + // Renaming the entry point rather than splicing a prologue into its body keeps the + // application's code byte-identical, including an early `return`. + String preamble = String("flat in highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";\n"; + preamble += String("uniform highp int ") + VIEWPORT_PASS_MASK_UNIFORM_NAME + ";\n"; + preamble += String("void ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "()"; + gated.replace(entryPos, std::strlen(ESSL_ENTRY_POINT_SIGNATURE), preamble); + + // `& 15` clamps the shift operand into range for MAX_VIEWPORTS = 16. GL leaves an index + // outside [0, MAX_VIEWPORTS) undefined, but an ESSL shift by >= 32 is undefined in a way + // that can take the whole draw with it, so the emulation picks a defined answer instead. + // + // The mask, not an equality test against a pass number: viewport indices whose whole + // state tuple is identical share ONE replay pass (see BeginViewportRoutingPasses), and + // the overwhelmingly common case - every index still holding what glViewport broadcast - + // is then a single pass with every bit set, i.e. a gate that discards nothing and a draw + // that is issued exactly once. + // + // PERFORMANCE NOTE: a fragment shader containing `discard` cannot take the early-Z fast + // path on a tiler, so a routed draw pays late-Z on top of its N replay passes. Accepted + // deliberately: this runs only for a program that writes gl_ViewportIndex, and that is + // why the gate is injected per program rather than into every fragment shader. + gated += "\n"; + gated += String(ESSL_ENTRY_POINT_SIGNATURE) + "\n"; + gated += "{\n"; + gated += String(" if (((") + VIEWPORT_PASS_MASK_UNIFORM_NAME + " >> (" + + VIEWPORT_INDEX_VARYING_NAME + " & 15)) & 1) == 0)\n"; + gated += " {\n"; + gated += " discard;\n"; + gated += " }\n"; + gated += " else\n"; + gated += " {\n"; + gated += String(" ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "();\n"; + gated += " }\n"; + gated += "}\n"; + source = std::move(gated); + return true; + } + // The transpile pipeline invents image binding numbers: when the GL source declares // an image uniform without layout(binding), glslang auto-assigns one (desktop GL // allows that and lets the app pick the unit with glUniform1i, which ES forbids on @@ -5493,7 +5623,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // keep the two in step. const Int advertisedMaxSamples = std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples); - const Bool viewportLoweringArmed = !g_GLESCapabilities.SupportsViewportArray; + // Armed by the EMULATION as well as by the missing extension, and the emulation is on + // by default (MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a + // reason to keep the builtin: it only ever gave the SHADER a compilable name, while + // the driver's INDEXED viewport state was never programmed by anything in MobileGL + // (SyncRenderState pushes index 0 and stops), so an extension-capable driver + // rasterized every index as index 0 exactly like a driver without it. Lowering here + // is what lets the ESSL passes downstream turn the builtin into the flat varying the + // replay gates on. + // + // Restricted to the three stages GL lets WRITE the builtin (4.1 core gives it to the + // geometry stage, ARB_shader_viewport_layer_array adds vertex and tessellation + // evaluation). A fragment stage's gl_ViewportIndex is an INPUT, which the pass + // declines anyway, and a compute stage has none - so arming those two only ever + // bought them the shared probe's BuildModule for nothing. + const Bool stageCanWriteViewportIndex = glShaderType == GL_VERTEX_SHADER || + glShaderType == GL_TESS_EVALUATION_SHADER || + glShaderType == GL_GEOMETRY_SHADER; + const Bool viewportLoweringArmed = + stageCanWriteViewportIndex && + (ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray); const Bool sampleClampArmed = g_GLESCapabilities.MaxColorTextureSamples < advertisedMaxSamples || g_GLESCapabilities.MaxIntegerSamples < advertisedMaxSamples || @@ -5517,11 +5666,13 @@ namespace MobileGL::MG_Backend::DirectGLES { *effectiveSpirv, loweredViewportSpirv, enableSpirvValidation) && !loweredViewportSpirv.empty()) { effectiveSpirv = &loweredViewportSpirv; - MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which this ES driver has " - "no GL_OES_viewport_array for. The builtin was demoted to a plain " - "global; every invocation renders into viewport 0.", + MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which ESSL has no core " + "spelling for. The builtin was demoted to a plain global; %s.", m_backendProgramId, - MG_Util::ConvertGLEnumToString(glShaderType).c_str()); + MG_Util::ConvertGLEnumToString(glShaderType).c_str(), + ViewportArrayEmulationEnabled() + ? "the ESSL passes below promote it to a routing varying" + : "every invocation renders into viewport 0"); } // GL 4.6 core table 23.53 requires GL_MAX_SAMPLES >= 4, so every multisample @@ -6233,7 +6384,40 @@ namespace MobileGL::MG_Backend::DirectGLES { } const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage; + // The stage order the loop below walks, with every FRAGMENT stage moved to the end. + // The viewport-routing gate is the reason: whether a fragment stage needs one is a + // question about the OTHER stages ("does any of them still write gl_ViewportIndex?"), + // and the honest, free answer to it is the promotion the producing stage's own text + // pass just performed. Answering it any other way costs a BuildModule per + // pre-rasterization stage of every program - the parse the shared SpirvGateFeatures + // probe exists to avoid. Nothing else in the loop is order-sensitive: the two + // passthrough-tessellation sources it captures are a vertex and an evaluation stage, + // and the three sets it accumulates are unions. + Vector stageOrder; + stageOrder.reserve(linkedStages.size()); for (SizeT index = 0; index < linkedStages.size(); ++index) { + if (linkedStages[index] != ShaderStage::Fragment) stageOrder.push_back(index); + } + for (SizeT index = 0; index < linkedStages.size(); ++index) { + if (linkedStages[index] == ShaderStage::Fragment) stageOrder.push_back(index); + } + // Set by whichever pre-rasterization stage's demoted mg_ViewportIndex global the text + // pass turned into a varying; read by the fragment stage to decide whether to inject + // the gate that consumes it. + Bool programRoutesViewportIndex = false; + // No fragment stage, no gate - and without a gate the promotion below would only add + // an output nothing can read. That is not merely useless: in a separable program + // pipeline the fragment stage lives in a DIFFERENT program, which never saw this + // build and cannot be given a gate, so promoting there would hang an unmatched + // varying off a program to buy nothing. Both cases keep the pre-emulation behaviour, + // which is what a program with no fragment stage had anyway. + const Bool programHasFragmentStage = + std::find(linkedStages.begin(), linkedStages.end(), ShaderStage::Fragment) != + linkedStages.end(); + const Bool viewportEmulationForThisProgram = + ViewportArrayEmulationEnabled() && programHasFragmentStage; + + for (const SizeT index : stageOrder) { GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(linkedStages[index]); GLuint backendShaderId = g_GLESFuncs.glCreateShader(glShaderType); @@ -6272,7 +6456,14 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ShaderTranspiler::EsslTranslationKeyInputs esslKeyInputs; esslKeyInputs.spirv = &spirvCode; esslKeyInputs.shaderType = glShaderType; - esslKeyInputs.supportsViewportArray = g_GLESCapabilities.SupportsViewportArray; + // The EFFECTIVE arming, computed the same way TranspileSpirvToEssl computes it. + // Duplicated rather than shared because the two live on opposite sides of the + // memo boundary - and a key that disagrees with the pass it is keying is the one + // failure mode of this cache that renders wrong pixels instead of being slow. + esslKeyInputs.viewportIndexLoweringArmed = + (glShaderType == GL_VERTEX_SHADER || glShaderType == GL_TESS_EVALUATION_SHADER || + glShaderType == GL_GEOMETRY_SHADER) && + (ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray); esslKeyInputs.supportsNoperspectiveInterpolation = g_GLESCapabilities.SupportsNoperspectiveInterpolation; esslKeyInputs.supportsExtendedImageFormats = @@ -6427,8 +6618,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // name; a driver without the extension took the LowerViewportIndexPass fallback // above and its source no longer names the builtin at all, so the two are mutually // exclusive by construction. Read `source` BEFORE it is moved from. - const Bool needsViewportArrayExtension = g_GLESCapabilities.SupportsViewportArray && - source.find("gl_ViewportIndex") != String::npos; + // The routing emulation is the third way this can be reached and the only one + // that needs no directive: it renames the fragment stage's read onto the varying + // the producing stage now writes, a few passes below. + const Bool needsViewportArrayExtension = + g_GLESCapabilities.SupportsViewportArray && + !(ViewportArrayEmulationEnabled() && programRoutesViewportIndex) && + source.find("gl_ViewportIndex") != String::npos; source = RequestViewportArrayExtension(std::move(source), needsViewportArrayExtension); source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject); @@ -6489,6 +6685,29 @@ namespace MobileGL::MG_Backend::DirectGLES { source = EmulateTextureLodBias(source, ShouldAvoidExplicitLodBiasOnAngleLlvmpipe()); source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType); source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType); + // The two halves of the gl_ViewportIndex routing emulation, next to the draw- + // parameter promotion because they are the same shape: a builtin ESSL cannot + // spell, demoted to a plain global by a SPIR-V pass, given a real interface here. + // BEFORE ForceSupporterOutput, so the `precision highp` statements it hoists to + // the top land above the declarations these inject; AFTER + // ForceFlatIntegerVaryings, which matches only declarations carrying a + // layout(...) qualifier and so cannot touch either of them. + if (viewportEmulationForThisProgram) { + if (glShaderType == GL_FRAGMENT_SHADER) { + if (programRoutesViewportIndex && !InjectViewportIndexPassGate(source)) { + // MGLOG_E, unlatched, like the transpile- and compile-failure + // diagnostics around it: the program still links and still draws, so + // nothing else in the process will ever say that its viewport routing + // silently collapsed back to one rectangle. + MGLOG_E("Program %u routes gl_ViewportIndex but its fragment stage has no " + "entry point to gate, so the routing cannot be emulated: every " + "index will rasterize against viewport 0. State program ID: %u.", + m_backendProgramId, stateProgramObject->GetExternalIndex()); + } + } else if (PromoteViewportIndexGlobalToVarying(source)) { + programRoutesViewportIndex = true; + } + } source = ForceSupporterOutput(source); source = ClampNormFallbackOutputs(std::move(source), glShaderType, m_snormFallbackClampOutputMask, @@ -6691,6 +6910,19 @@ namespace MobileGL::MG_Backend::DirectGLES { BASE_VERTEX_UNIFORM_NAME); m_baseInstanceWordIndexUniformLocation = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME); + // Asked of the DRIVER rather than remembered from the injection, deliberately: the + // gate is only real if the uniform survived compilation and linking, and this is the + // one question whose answer covers both. A gate the driver optimized away would + // otherwise leave the draw path replaying passes whose mask reaches nothing, which + // renders every index's primitives in every pass. + m_viewportPassMaskUniformLocation = + g_GLESFuncs.glGetUniformLocation(m_backendProgramId, VIEWPORT_PASS_MASK_UNIFORM_NAME); + if (m_viewportPassMaskUniformLocation >= 0) { + // Sticky, and never cleared on a relink: it only ever short-circuits a per-draw + // check, so being late to go false costs a pointer compare and being late to go + // true would cost correctness. + g_anyProgramRoutesViewportIndex = true; + } // The mg_IndirectParams block binding is baked into the ESSL (ES cannot rebind // SSBO blocks after compile); record it so draws bind the indirect buffer there. m_indirectParamsBinding = -1; @@ -6893,6 +7125,13 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_GLESFuncs.glUniform1i(m_drawIdUniformLocation, static_cast(drawId)); } + + void BackendProgramObjectImpl::SetViewportPassMask(Uint32 indexMask) const { + if (m_viewportPassMaskUniformLocation < 0) { + return; + } + g_GLESFuncs.glUniform1i(m_viewportPassMaskUniformLocation, static_cast(indexMask)); + } } // namespace PrgramImpl namespace SamplerImpl { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index cea92dfc..c33a138a 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -21,6 +21,19 @@ namespace MobileGL::MG_Backend::DirectGLES { String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType); String PromoteDrawParameterGlobalsToUniforms(String source, GLenum shaderType); + // The ESSL half of the gl_ViewportIndex routing emulation, in the order a program's stages + // meet it. Both are pure String -> String rewrites over what SPIRV-Cross emitted once + // LowerViewportIndexPass has demoted the builtin to the plain global `mg_ViewportIndex`. + // + // The producing stage's global becomes an ordinary flat varying; true when there was one to + // promote, which is also the answer to "does this program route viewports at all". + Bool PromoteViewportIndexGlobalToVarying(String& source); + // The fragment stage grows a matching flat input, the mg_ViewportPassMask uniform the draw + // path writes, and a wrapper entry point that discards every fragment whose primitive routed + // to an index the current replay pass is not drawing. False when the stage has no entry point + // to wrap, which leaves the program renderable but unrouted. + Bool InjectViewportIndexPassGate(String& source); + // Whether a vertex shader may declare a storage block at all, given what the host driver // reports for GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS. Pure, and separated from the capability // global purely so the decision can be tested without one. @@ -113,6 +126,58 @@ namespace MobileGL::MG_Backend::DirectGLES { // link. Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices); + // ---- gl_ViewportIndex routing emulation, draw half --------------------------------------- + // + // GLES has ONE viewport, ONE scissor rectangle and ONE depth range; GL 4.1 has sixteen of + // each, selected per primitive by gl_ViewportIndex. There is no ES entry point to program the + // other fifteen with (GL_OES_viewport_array exists but Adreno 830 does not have it, verified + // three ways), so the only way to rasterize a primitive against index i's rectangle is to + // make index i's rectangle THE viewport for the duration of a draw - which means issuing the + // draw once per distinct viewport state and letting the fragment stage throw away the + // primitives that belong to the other indices (the gate Managers.cpp injects). + // + // Indices whose whole state tuple (viewport rectangle, scissor rectangle, scissor-test enable, + // depth range) is identical share ONE pass, so the overwhelmingly common case - every index + // still holding what glViewport/glScissor/glDepthRange broadcast to all sixteen - collapses + // to a single pass with an all-ones gate mask, i.e. one draw and no behaviour change at all. + // + // Whether emulation runs. Off only under MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION falsy, which + // restores the pre-emulation path as a negative control. + Bool ViewportArrayEmulationEnabled(); + // Whether ANY program built in this process has come out with a viewport gate. Sticky once + // true; it exists so that BeginViewportRoutingPasses - which runs on every draw of every + // workload - can answer with one static load in the case that matters, which is every + // application that has never heard of gl_ViewportIndex. + extern Bool g_anyProgramRoutesViewportIndex; + // Number of times the current draw has to be issued. Always >= 1, and exactly 1 - with no + // state touched - whenever the current program does not route viewports, whenever every + // configured index shares one state, and whenever replaying would multiply a side effect the + // fragment gate cannot undo (transform feedback, rasterizer discard). Also seeds the pass + // mask uniform for that single-pass case, so a gated fragment shader never runs against the + // zero every GLSL uniform starts at - which would discard the whole draw. + Uint BeginViewportRoutingPasses(); + // Push pass `pass`'s viewport / scissor / scissor-test / depth range onto the ES context and + // set the gate mask to the indices it serves. Only called when the count above exceeds 1. + void ApplyViewportRoutingPass(Uint pass); + // Restore the gate mask and mark the render-state shadow dirty, so the next ordinary draw + // re-pushes index 0's state. Takes the count so it can do nothing at all in the common case. + void EndViewportRoutingPasses(Uint passCount); + + // Issue one draw, replayed once per viewport-routing pass. Every application-visible draw + // entry point wraps its native glDraw* call in this; the internal blit and clear helpers + // deliberately do not, because they bind their own programs, which never route. + template + inline void ForEachViewportRoutingPass(IssueDraw&& issue) { + const Uint passCount = BeginViewportRoutingPasses(); + for (Uint pass = 0; pass < passCount; ++pass) { + if (passCount > 1) { + ApplyViewportRoutingPass(pass); + } + issue(); + } + EndViewportRoutingPasses(passCount); + } + template class StateBackendObjectRegistry { public: @@ -1200,6 +1265,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // Same for gl_BaseVertex: only a program that reads it pays for the per-draw // uniform write, and only such a program needs the reset after one. Bool ReadsBaseVertex() const { return m_baseVertexUniformLocation >= 0; } + // Which viewport indices the next draw's fragments may keep, one bit each. Written + // once per replay pass; see ForEachViewportRoutingPass. + void SetViewportPassMask(Uint32 indexMask) const; + // True when this build injected the fragment-stage viewport gate, i.e. when a + // pre-rasterization stage routes by gl_ViewportIndex AND the fragment stage can act + // on it. The uniform is the honest test for both halves: it exists only where the + // gate was injected, and the gate is injected only where a stage routes. + Bool RoutesViewportIndex() const { return m_viewportPassMaskUniformLocation >= 0; } Int GetIndirectParamsBinding() const { return m_indirectParamsBinding; } Uint GetBackendProgramId() const { return m_backendProgramId; } // False when the last SyncToBackend could not produce a usable program (a @@ -1316,6 +1389,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Int m_drawIdUniformLocation = -1; Int m_baseVertexUniformLocation = -1; Int m_baseInstanceWordIndexUniformLocation = -1; + Int m_viewportPassMaskUniformLocation = -1; Int m_indirectParamsBinding = -1; Uint32 m_snormFallbackClampOutputMask = 0; Uint32 m_unormFallbackClampOutputMask = 0; diff --git a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp index 814da888..db009d52 100644 --- a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp +++ b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp @@ -414,14 +414,18 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { const Uint previousIndirectBinding = BoundDrawIndirectBufferId(); BufferImpl::BindBufferId(GL_DRAW_INDIRECT_BUFFER, g_indirectCommands.id); if (batched) { - g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast(commandBase), - drawcount, 0); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glMultiDrawElementsIndirectEXT(mode, type, reinterpret_cast(commandBase), + drawcount, 0); + }); } else { for (GLsizei i = 0; i < drawcount; ++i) { if (feedDrawID) SetCurrentDrawID(static_cast(i)); if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0); const SizeT commandOffset = commandBase + static_cast(i) * sizeof(DrawElementsIndirectCommand); - g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast(commandOffset)); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsIndirect(mode, type, reinterpret_cast(commandOffset)); + }); } if (feedDrawID) SetCurrentDrawID(0); if (feedBaseVertex) SetCurrentBaseVertex(0); @@ -442,8 +446,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (count[i] <= 0) continue; if (feedDrawID) SetCurrentDrawID(static_cast(i)); if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0); - g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i], - basevertex ? basevertex[i] : 0); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElementsBaseVertex(mode, count[i], type, indices[i], + basevertex ? basevertex[i] : 0); + }); } if (feedDrawID) SetCurrentDrawID(0); if (feedBaseVertex) SetCurrentBaseVertex(0); @@ -515,8 +521,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // driver sees none - but gl_BaseVertex still has to report the value the // application passed for this sub-draw. if (feedBaseVertex) SetCurrentBaseVertex(basevertex ? basevertex[i] : 0); - g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT, - reinterpret_cast(indexBase + cursor * sizeof(Uint32))); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElements(mode, count[i], GL_UNSIGNED_INT, + reinterpret_cast(indexBase + cursor * sizeof(Uint32))); + }); cursor += static_cast(count[i]); } if (feedDrawID) SetCurrentDrawID(0); @@ -870,7 +878,9 @@ void main() { if (flattened.indexCount != 0) { const Uint previousIndexBinding = BoundIndexBufferId(); BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, flattened.bufferId); - g_GLESFuncs.glDrawElements(mode, static_cast(flattened.indexCount), GL_UNSIGNED_INT, nullptr); + ForEachViewportRoutingPass([&] { + g_GLESFuncs.glDrawElements(mode, static_cast(flattened.indexCount), GL_UNSIGNED_INT, nullptr); + }); BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, previousIndexBinding); return; } diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 54f33007..53bfd30d 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -169,7 +169,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { TranslationKeyBuilder builder; AppendCommonKeyPrefix(builder, kEsslKeyTag); builder.Value(static_cast(inputs.shaderType)); - builder.Value(static_cast(inputs.supportsViewportArray)); + builder.Value(static_cast(inputs.viewportIndexLoweringArmed)); builder.Value(static_cast(inputs.supportsNoperspectiveInterpolation)); builder.Value(static_cast(inputs.supportsExtendedImageFormats)); builder.Value(inputs.maxColorTextureSamples); diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index d4645fa0..cc17ee69 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -556,7 +556,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // * the GL stage enum - three passes are stage-gated (draw parameters and // array vertex inputs on vertex, fragment-output index legalization on // fragment); - // * SupportsViewportArray - arms LowerViewportIndexForEssl; + // * the viewport-index lowering arming bit - GL_OES_viewport_array's absence OR the + // routing emulation being on - which arms LowerViewportIndexForEssl; // * the four sample ceilings (color / integer / depth / advertised) - both // ARM ClampMultisampleFetchesForEssl and PARAMETERIZE it; // * SupportsNoperspectiveInterpolation - arms EmulateNoPerspectiveForEssl; @@ -583,7 +584,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // armed by nothing, so the SPIR-V already in this key covers them completely. // // THE TEST FOR THAT CLAIM IS NOT THE SIGNATURE. LowerViewportIndexForEssl is equally - // module-only to look at, yet SupportsViewportArray is in this key because that bit ARMS + // module-only to look at, yet the arming bit is in this key because it ARMS // it at the call site. So a new pass needs BOTH checks - what it takes, and what decides // whether it runs - before "no key material" is a conclusion rather than an assumption. // Note also where an application-authored value can hide: the atomic-counter @@ -612,7 +613,12 @@ namespace MobileGL::MG_Util::ShaderTranspiler { GLenum shaderType = 0; // --- driver capability bits that arm or steer a pass --- - Bool supportsViewportArray = false; + // Whether LowerViewportIndexForEssl runs on this module. NOT the raw + // GL_OES_viewport_array bit any more: the routing emulation arms the pass even where the + // extension exists (Config.h, ViewportArrayEmulation), so the extension alone no longer + // decides, and a key carrying only it would serve a module lowered under one setting to a + // link made under the other. + Bool viewportIndexLoweringArmed = false; Bool supportsNoperspectiveInterpolation = false; // GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage // image whose format GLSL ES core cannot spell in the core format that carries it and From 38c56a3d388907fbf5e0cb6c580f1f78a0ea7e57 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 21 Aug 2026 22:08:33 -0400 Subject: [PATCH 6/6] [Test] (DirectGLES, IntegrationTest): run the viewport-array scenarios on Espryt and pin the routing rewrites against a negative control --- .../Scenarios/ViewportArrayScenario.cpp | 112 +++++++++-- .../MG_Test/Backend/DirectGLES/CMakeLists.txt | 17 ++ .../DirectGLES/ViewportIndexRoutingTest.cpp | 179 ++++++++++++++++++ .../ShaderTranspiler/TranslationCacheTest.cpp | 8 +- 4 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp index 44a75679..18f0077b 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp @@ -30,15 +30,23 @@ // applies the flip to viewport 0 and forgets the other fifteen renders a correct-looking FBO and // an upside-down window - the classic multi-viewport bug, and invisible to every FBO-only case. // -// HONEST LIMIT OF THIS FILE. DirectGLES SKIPS every case: GLES has one viewport, one scissor -// rectangle and no gl_ViewportIndex, so routing to index > 0 is an emulation feature that has -// not been built (the Espryt half of KHR-GL43.viewport_array's rendering group is deliberately -// still red). The skip is explicit rather than silent so a future emulation lands here as a -// failing test and not as a test that was quietly never running. DirectVulkan additionally -// skips when the device lacks the multiViewport feature - Vulkan then forbids a pipeline from -// declaring more than one viewport at all, which is a device limit and not a MobileGL bug; -// lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do run where -// it matters. +// BOTH BACKENDS RUN EVERY CASE, by two completely different routes, which is the point of +// keeping them in one file. DirectVulkan declares sixteen viewports on the pipeline and lets the +// hardware route. DirectGLES has one viewport, one scissor rectangle and one depth range and no +// gl_ViewportIndex at all, so it EMULATES: the builtin becomes a flat varying, the fragment stage +// gets a gate, and the draw is replayed once per distinct viewport state (Managers.h, +// ForEachViewportRoutingPass). Every assertion below is about pixels, so it cannot tell the two +// apart - which is exactly what has to be true. +// +// DirectVulkan skips when the device lacks the multiViewport feature - Vulkan then forbids a +// pipeline from declaring more than one viewport at all, which is a device limit and not a +// MobileGL bug; lavapipe (every CI lane) and both Mali/Adreno devices support it, so the cases do +// run where it matters. +// +// The last case is the negative control for the emulation and runs on DirectGLES only: it builds +// the SAME program with the emulation switched off and requires the routing to collapse onto +// viewport 0. Without it every assertion above could be satisfied by a backend that happened to +// be right for some other reason, and the emulation's own switch would be untested. #include #include @@ -47,6 +55,10 @@ #include "../Harness/HeadlessGL.h" #include "../Harness/ScenarioFixture.h" +// For the emulation switch the negative-control case below flips. Nothing else in this file needs +// to know which backend it is running on. +#include + #ifdef GLAPI #undef GLAPI #endif @@ -142,13 +154,6 @@ void main() { fragColor = gl_FragCoord.z; } ScenarioTest::SetUp(); if (!Ready()) return; - if (Gl().BackendName() == "DirectGLES") { - GTEST_SKIP() << "gl_ViewportIndex routing is not emulated on DirectGLES: GLES has one viewport " - "and one scissor rectangle, so every index rasterizes as index 0. The indexed " - "STATE is still asserted (MG_Test RenderStateTest); this is the deferred " - "rendering half of KHR-GL43.viewport_array."; - } - GLint maxViewports = 0; glGetIntegerv(GL_MAX_VIEWPORTS, &maxViewports); ASSERT_GE(maxViewports, kViewportCount) << "GL 4.3 core requires GL_MAX_VIEWPORTS >= 16"; @@ -520,11 +525,78 @@ void main() { fragColor = vec4(float(gsIndex) * 16.0 / 255.0, 0.0, 0.0, 1.0); } DestroyIntTarget(target); } - // --- 4. an explicitly EMPTY scissor box clips, it does not mean "never written" -------- + // --- 4. the negative control for the DirectGLES emulation ----------------------------- // - // Deliberately NOT a ViewportArrayScenario case, because it must run on DirectGLES - the - // backend that got it wrong - and that fixture skips there. It needs none of the routing: - // one viewport, one scissor rectangle, no geometry stage. + // Everything above is a claim about pixels, and a claim about pixels cannot tell an + // emulation that works from a backend that was going to be right anyway. This case builds + // the SAME program with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION off and requires case 1's + // result to COLLAPSE: with no routing, every geometry invocation rasterizes against + // viewport 0's rectangle, so the last invocation paints the whole surface and every cell + // reads 15 instead of its own index. That is the pre-emulation behaviour this backend had + // (and the failure signature KHR-GL43.viewport_array reported on it), pinned here so that + // (a) the three cases above are known to be testing the emulation and not the weather, + // and (b) the switch itself has a test. + // + // DirectGLES only: the flag steers nothing on DirectVulkan, which routes natively. + TEST_F(ViewportArrayScenario, WithoutTheEmulationEveryIndexCollapsesOntoViewportZero) { + if (Gl().BackendName() != "DirectGLES") { + GTEST_SKIP() << "the emulation switch is a DirectGLES concern; DirectVulkan routes " + "gl_ViewportIndex natively and ignores it"; + } + + // The feature table is a process-global and this fixture shares its context with every + // other scenario in the process, so the restore is not optional. + struct ScopedEmulationOff { + ScopedEmulationOff(): saved(MobileGL::MG_Config::Features.ViewportArrayEmulation) { + MobileGL::MG_Config::Features.ViewportArrayEmulation = + MobileGL::MG_Config::QuirkOverride::ForceOff; + } + ~ScopedEmulationOff() { MobileGL::MG_Config::Features.ViewportArrayEmulation = saved; } + MobileGL::MG_Config::QuirkOverride saved; + }; + + IntTarget target = MakeIntTarget(kSurfaceSide, kSurfaceSide); + SetupGridViewports(kCellSize, kCellSize); + + GLuint unroutedProgram = 0; + { + const ScopedEmulationOff scopedEmulationOff; + // A FRESH program: the emitted ESSL is decided at link time and memoized on a key + // that carries this flag, so reusing m_program would just replay the routed build. + unroutedProgram = BuildProgram(kGridGeometrySource, kIntFragmentSource); + ASSERT_NE(unroutedProgram, 0u) << "unrouted program failed to build: " << m_buildLog; + glUseProgram(unroutedProgram); + glBindVertexArray(m_vao); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_EQ(glGetError(), GL_NO_ERROR); + } + + const std::vector pixels = ReadInts(kSurfaceSide, kSurfaceSide); + // Cell (0, 0) IS viewport 0's rectangle, so it is the one cell an unrouted draw paints + // with something. Everything it holds comes from the last geometry invocation. + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, 0, 0), kViewportCount - 1) + << "with the emulation off, viewport 0's rectangle must hold the LAST invocation's " + "index - if it holds 0 the routing is still happening and this control proves " + "nothing"; + for (int y = 0; y < kGridSide; ++y) { + for (int x = 0; x < kGridSide; ++x) { + if (x == 0 && y == 0) continue; + EXPECT_EQ(CellCentre(pixels, kSurfaceSide, x, y), kUnwritten) + << "cell (" << x << ", " << y << ") is outside viewport 0's rectangle and an " + << "unrouted draw cannot reach it"; + } + } + + glUseProgram(0); + glDeleteProgram(unroutedProgram); + DestroyIntTarget(target); + } + + // --- 5. an explicitly EMPTY scissor box clips, it does not mean "never written" -------- + // + // Deliberately NOT a ViewportArrayScenario case, because that fixture's geometry stage + // routes and this claim needs none of it: one viewport, one scissor rectangle, no + // geometry stage - and it has to hold identically whether or not anything routes. // // glScissor(0, 0, 0, 0) is legal GL meaning "the scissor test rejects every fragment", // but it is byte-identical to the all-zero rectangle a context starts with, whose meaning diff --git a/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt b/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt index cab9b68f..8393a411 100644 --- a/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt +++ b/MobileGL/MG_Test/Backend/DirectGLES/CMakeLists.txt @@ -32,6 +32,23 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + ViewportIndexRoutingTest + ViewportIndexRoutingTest.cpp +) + +target_include_directories(ViewportIndexRoutingTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + ViewportIndexRoutingTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + include(GoogleTest) gtest_discover_tests(EsslShaderPassTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(BaseInstanceInjectionTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(ViewportIndexRoutingTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.cpp b/MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.cpp new file mode 100644 index 00000000..5959d4ba --- /dev/null +++ b/MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.cpp @@ -0,0 +1,179 @@ +// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/ViewportIndexRoutingTest.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 +// +// The ESSL half of the gl_ViewportIndex routing emulation (MG_Backend/DirectGLES/Managers.cpp). +// GLES has one viewport, one scissor rectangle and one depth range where GL 4.1 has sixteen of +// each selected per primitive, and the target device has no GL_OES_viewport_array to borrow, so +// DirectGLES turns the builtin into an ordinary flat varying and gives the fragment stage a gate +// the draw path replays against. +// +// Both passes are pure String -> String over what SPIRV-Cross emits once LowerViewportIndexPass +// has demoted the builtin, so no GL context and no driver: the shapes they have to survive - and +// the ones they must refuse - can be pinned here rather than only on a device. What they cannot +// pin is that the routing produces the right pixels; that is +// MG_IntegrationTest/Scenarios/ViewportArrayScenario.cpp, which runs the same claim through both +// backends. + +#include + +#include + +using MobileGL::Bool; +using MobileGL::String; +using MobileGL::MG_Backend::DirectGLES::InjectViewportIndexPassGate; +using MobileGL::MG_Backend::DirectGLES::PromoteViewportIndexGlobalToVarying; + +namespace { + Bool Contains(const String& haystack, const String& needle) { + return haystack.find(needle) != String::npos; + } + + // What SPIRV-Cross hands the backend for a geometry stage after LowerViewportIndexPass has + // demoted gl_ViewportIndex: a plain file-scope global the shader still writes and which, until + // this pass runs, nothing anywhere reads. + constexpr const char* kLoweredGeometryShader = R"(#version 320 es +layout(invocations = 16, points) in; +layout(max_vertices = 4, triangle_strip) out; + +layout(location = 0) flat out int gsIndex; +int mg_ViewportIndex; + +void main() +{ + gsIndex = gl_InvocationID; + mg_ViewportIndex = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); + EmitVertex(); + EndPrimitive(); +} +)"; + + constexpr const char* kFragmentShader = R"(#version 320 es +precision mediump float; +precision highp int; + +layout(location = 0) flat in int gsIndex; +layout(location = 0) out highp vec4 fragColor; + +void main() +{ + fragColor = vec4(float(gsIndex)); +} +)"; +} // namespace + +// The promotion itself. The declaration becomes an interface variable and the STORE is left +// exactly where it was - the pass must not touch the body, because the body is the application's. +TEST(ViewportIndexRoutingTest, TheDemotedGlobalBecomesAFlatVarying) { + String source = kLoweredGeometryShader; + ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source; + + EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source; + EXPECT_FALSE(Contains(source, "\nint mg_ViewportIndex;")) << source; + EXPECT_TRUE(Contains(source, " mg_ViewportIndex = gl_InvocationID;")) << source; +} + +// FLAT is the semantics and not a hint: GL takes a primitive's viewport index from its provoking +// vertex, and flat interpolation is what delivers that. An interpolated integer would not even +// compile in ESSL, so losing the qualifier fails loudly - but silently losing it to a `smooth` +// rewrite somewhere downstream would route by whichever vertex the rasterizer felt like. +TEST(ViewportIndexRoutingTest, ThePromotedVaryingIsFlatAndCarriesNoExplicitLocation) { + String source = kLoweredGeometryShader; + ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)); + + const size_t declPos = source.find("flat out highp int mg_ViewportIndex;"); + ASSERT_NE(declPos, String::npos) << source; + // No layout(location = N): the two stages are transpiled independently and cannot agree on a + // number, so the varying is matched by NAME. A location that appeared here would have to + // appear identically in the fragment stage, which nothing can guarantee. + const size_t lineStart = source.rfind('\n', declPos); + const String declLine = source.substr(lineStart + 1, declPos - lineStart - 1); + EXPECT_EQ(declLine, "") << "the declaration must start its own line, with no layout qualifier"; +} + +// A precision-qualified declaration is the same declaration. SPIRV-Cross prints one or the other +// depending on what the module carried, and a pass that only matched the bare form would leave +// half the drivers unrouted while reporting success. +TEST(ViewportIndexRoutingTest, APrecisionQualifiedDeclarationIsPromotedToo) { + String source = "#version 320 es\nhighp int mg_ViewportIndex;\nvoid main() { mg_ViewportIndex = 3; }\n"; + ASSERT_TRUE(PromoteViewportIndexGlobalToVarying(source)) << source; + EXPECT_TRUE(Contains(source, "flat out highp int mg_ViewportIndex;")) << source; +} + +// A stage that never routed must come out byte-identical, because every stage of every program on +// this backend goes through the pass. +TEST(ViewportIndexRoutingTest, AStageWithoutTheGlobalIsUntouched) { + const String before = kFragmentShader; + String source = before; + EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source)); + EXPECT_EQ(source, before); +} + +// The one shape that would silently break a shader: a name that ends in mg_ViewportIndex but is +// not the declaration. Only a declaration starting its own line may be rewritten. +TEST(ViewportIndexRoutingTest, ADeclarationThatIsNotAtLineStartIsRefused) { + const String before = "#version 320 es\nuniform highp int mg_ViewportIndex;\nvoid main() {}\n"; + String source = before; + EXPECT_FALSE(PromoteViewportIndexGlobalToVarying(source)); + EXPECT_EQ(source, before); +} + +// The fragment gate. Three things have to be true at once: the varying and the uniform are +// declared, the application's entry point survives under a new name, and the new entry point +// discards on a mask miss and calls the old one otherwise. +TEST(ViewportIndexRoutingTest, TheFragmentGateWrapsTheEntryPoint) { + String source = kFragmentShader; + ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source; + + EXPECT_TRUE(Contains(source, "flat in highp int mg_ViewportIndex;")) << source; + EXPECT_TRUE(Contains(source, "uniform highp int mg_ViewportPassMask;")) << source; + EXPECT_TRUE(Contains(source, "void mg_ViewportGatedMain()")) << source; + EXPECT_TRUE(Contains(source, "discard;")) << source; + EXPECT_TRUE(Contains(source, "mg_ViewportGatedMain();")) << source; + // The application's body is not edited, only renamed. + EXPECT_TRUE(Contains(source, " fragColor = vec4(float(gsIndex));")) << source; + // Exactly one entry point remains, and it is the wrapper. + EXPECT_EQ(source.find("void main()"), source.rfind("void main()")) << source; +} + +// The shift operand has to be clamped. GL leaves a gl_ViewportIndex outside [0, MAX_VIEWPORTS) +// undefined and the emulation is free to pick anything, but an ESSL shift by >= 32 is undefined +// in a way that can take the whole draw with it - so the gate must not be able to reach one. +TEST(ViewportIndexRoutingTest, TheGateClampsTheShiftIntoRange) { + String source = kFragmentShader; + ASSERT_TRUE(InjectViewportIndexPassGate(source)); + EXPECT_TRUE(Contains(source, "mg_ViewportPassMask >> (mg_ViewportIndex & 15)")) << source; +} + +// A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either, and the +// routing varying is exactly the value it wanted. This is the only place the read can be repaired +// - LowerViewportIndexPass deliberately demotes outputs only, because a demoted input would +// answer from an undefined global. +TEST(ViewportIndexRoutingTest, AFragmentStageReadOfTheBuiltinIsRedirectedOntoTheVarying) { + String source = R"(#version 320 es +precision highp int; +layout(location = 0) out highp vec4 fragColor; +void main() +{ + fragColor = vec4(float(gl_ViewportIndex)); +} +)"; + ASSERT_TRUE(InjectViewportIndexPassGate(source)) << source; + EXPECT_FALSE(Contains(source, "gl_ViewportIndex")) << source; + EXPECT_TRUE(Contains(source, "fragColor = vec4(float(mg_ViewportIndex));")) << source; +} + +// A stage the pass declines must reach the driver exactly as it arrived, not half-rewritten. +// The caller logs the decline and the program still renders - unrouted, which is the old +// behaviour - so a partially edited source here would turn a degradation into a broken shader. +TEST(ViewportIndexRoutingTest, AStageWithNoEntryPointIsDeclinedWithoutBeingEdited) { + const String before = "#version 320 es\nprecision highp int;\nhighp int f() { return gl_ViewportIndex; }\n"; + String source = before; + EXPECT_FALSE(InjectViewportIndexPassGate(source)); + EXPECT_EQ(source, before); +} diff --git a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp index f4851cdf..9c3ced4d 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp @@ -135,7 +135,7 @@ void main() { EsslTranslationKeyInputs inputs; inputs.spirv = &spirv; inputs.shaderType = GL_FRAGMENT_SHADER; - inputs.supportsViewportArray = false; + inputs.viewportIndexLoweringArmed = false; inputs.supportsNoperspectiveInterpolation = false; inputs.maxColorTextureSamples = 4; inputs.maxIntegerSamples = 1; @@ -855,8 +855,8 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) { } { // arms LowerViewportIndexForEssl EsslTranslationKeyInputs v = base; - v.supportsViewportArray = true; - variants.emplace_back("supportsViewportArray", BuildEsslTranslationKey(v)); + v.viewportIndexLoweringArmed = true; + variants.emplace_back("viewportIndexLoweringArmed", BuildEsslTranslationKey(v)); } { // arms EmulateNoPerspectiveForEssl EsslTranslationKeyInputs v = base; @@ -1008,7 +1008,7 @@ TEST_F(TranslationCacheTest, L2RunsTheEmitterOncePerDistinctKey) { // ... and a gate that only steers the SPIR-V pass chain still moves the key, so the // emitter runs again even though this stand-in ignores the bit. inputs = BaselineEsslInputs(spirv); - inputs.supportsViewportArray = true; + inputs.viewportIndexLoweringArmed = true; (void)translate(inputs); EXPECT_EQ(emitCount, 3); }