diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 10428043..4472120b 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1772,6 +1772,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // clear-then-draw pair on an unchanged parameter block early-outs and the draw inherits // the clear's undoctored mask. static Uint32 g_syncedColorMaskAlphaWidenMask = 0; + // Scratch for the dual-source-blend decline path in the blend block below. File-scope + // rather than a local so the ordinary draw pays nothing for it: it is written only on a + // driver with no GL_EXT_blend_func_extended that is also handed a GL_SRC1_* factor, and + // SyncRenderState runs on the GL thread only. + static Array + g_dualSourceDeclinedBlendStates; void InvalidateSyncedRenderState() { g_forceFullRenderStateResync = true; g_hasSyncedRenderState = false; @@ -1931,31 +1937,66 @@ namespace MobileGL::MG_Backend::DirectGLES { const auto& ToGLBoolean = [](Bool b) -> GLboolean { return b ? GL_TRUE : GL_FALSE; }; + // Which draw buffers the blend block below DECLINED (see it for why). Needed again at + // the shadow write-back at the end of this function: the span memcpy there clones the + // FRONTEND block, which for a declined draw buffer is not what the driver was handed. + Uint32 dualSourceDeclinedMask = 0; + if (blendSpanDirty) { // Blend State using FBO = MG_State::GLState::FramebufferObject; - const auto& targetStates = parameters.BlendStates; auto& syncedStates = g_syncedRenderStateParameters.BlendStates; // Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with // glBindFragDataLocationIndexed) needs GL_EXT_blend_func_extended; GLES core has none. - // Detected at load and surfaced in the POST. There is no fallback, so if a draw actually - // enables blending with a SRC1 factor on a driver that lacks it, hard-fail here at use - // time rather than let the driver reject glBlendFuncSeparate and silently mis-blend. + // Detected at load and surfaced in the POST. There is no fallback that BLENDS + // correctly, so a draw that asks for a SRC1 factor on a driver without the extension + // gets the blend DECLINED: that draw buffer is pushed with blending off and neutral + // One/Zero factors, and the loss is logged once. The two rejected alternatives are + // both worse - pushing GL_SRC1_* at glBlendFuncSeparate leaves the driver to raise + // GL_INVALID_ENUM and keep whatever factors were there before (a silent mis-blend + // against stale state), and throwing, which is what this did until now, takes the + // whole process down over one unsupported blend factor. Declining is defined, + // survivable and visible in the log. + const auto* effectiveBlendStates = ¶meters.BlendStates; if (!g_GLESCapabilities.SupportsDualSourceBlend) { for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { - const auto& s = targetStates[i]; + const auto& s = parameters.BlendStates[i]; if (s.Enabled && (IsDualSourceBlendFactor(s.SrcFactorRGB) || IsDualSourceBlendFactor(s.DstFactorRGB) || IsDualSourceBlendFactor(s.SrcFactorAlpha) || IsDualSourceBlendFactor(s.DstFactorAlpha))) { - THROW_EXCEPTION( - "Dual-source blending (GL_SRC1_* blend factor) was used on draw buffer " + - std::to_string(i) + - ", but the GLES driver does not expose GL_EXT_blend_func_extended (see the " - "dual-source blend row in the driver POST). No fallback exists; the draw " - "cannot proceed."); + dualSourceDeclinedMask |= 1u << i; } } + if (dualSourceDeclinedMask != 0) { + MGLOG_E_ONCE( + "SyncRenderState: dual-source blending (GL_SRC1_* blend factor) was requested on " + "draw buffer mask 0x%x, but the GLES driver does not expose " + "GL_EXT_blend_func_extended (see the dual-source blend row in the driver POST). " + "Blending is DECLINED on those draw buffers - the fragment's first output is " + "written unblended and the second source is dropped.", + dualSourceDeclinedMask); + g_dualSourceDeclinedBlendStates = parameters.BlendStates; + for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { + if ((dualSourceDeclinedMask & (1u << i)) == 0) continue; + auto& s = g_dualSourceDeclinedBlendStates[i]; + s.Enabled = false; + // Neutral factors as well as the disable: the factor push below is not + // gated on Enabled (one glBlendFuncSeparate serves every draw buffer when + // they agree), so leaving Src1Color here would still hand the driver a + // GL_SRC1_* enum it cannot parse. + s.SrcFactorRGB = BlendFactor::One; + s.DstFactorRGB = BlendFactor::Zero; + s.SrcFactorAlpha = BlendFactor::One; + s.DstFactorAlpha = BlendFactor::Zero; + } + effectiveBlendStates = &g_dualSourceDeclinedBlendStates; + } } + // The rest of the block reads the EFFECTIVE state. The per-field writes it makes + // into `syncedStates` are provisional - the span memcpy at the end of this function + // overwrites the whole blend span with the frontend's own bytes - so the declined + // draw buffers are put back there, see the write-back below. + const auto& targetStates = *effectiveBlendStates; Bool allEnabled = true; Bool allDisabled = true; @@ -2355,6 +2396,18 @@ namespace MobileGL::MG_Backend::DirectGLES { if (blendSpanDirty) { std::memcpy(syncedBytesMut + kBlendSpanBegin, currentBytes + kBlendSpanBegin, kBlendSpanEnd - kBlendSpanBegin); + // ...except for a draw buffer whose dual-source blend was DECLINED, where the + // frontend block is precisely what did NOT reach the driver. The shadow has to hold + // what was pushed or the next diff compares against state the ES context never got: + // going from a SRC1 factor to an ordinary one leaves Enabled equal on both sides, + // the enable block finds nothing to do, and blending stays off from the decline. + // The span stays permanently "dirty" against the frontend as a result, which costs + // one memcmp plus this block per render-state VERSION change - the top-of-function + // version early-out still skips repeat draws entirely. + for (Uint i = 0; i < MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; ++i) { + if ((dualSourceDeclinedMask & (1u << i)) == 0) continue; + g_syncedRenderStateParameters.BlendStates[i] = g_dualSourceDeclinedBlendStates[i]; + } } if (tailSpanDirty) { std::memcpy(syncedBytesMut + kBlendSpanEnd, currentBytes + kBlendSpanEnd, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index ad30794a..1c504a5a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -5533,18 +5533,32 @@ void main() { } } // Dual-source blending (GL_SRC1_* factors from glBlendFunc paired with - // glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected at - // device creation and surfaced in the POST; if a shader actually issues a draw with a SRC1 - // factor on a device that lacks it, there is no fallback, so hard-fail here at use time - // rather than silently mistranslating the blend equation. - if (effectiveBlendEnabled && !m_dualSrcBlendFeatureEnabled && + // glBindFragDataLocationIndexed) requires the dualSrcBlend device feature. It is detected + // at device creation and surfaced in the POST; there is no fallback that BLENDS correctly, + // so a draw that asks for a SRC1 factor on a device without the feature gets the blend + // DECLINED - this attachment is baked with blending off and neutral One/Zero factors, and + // the loss is logged once. Both the factors AND the enable have to be neutralised: + // VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three + // siblings forbid a VK_BLEND_FACTOR_SRC1_* in the struct without the feature whatever + // blendEnable says, so clearing only the enable would still be invalid pipeline state. + // The previous behaviour, throwing, took the whole process down over one unsupported + // blend factor; this is defined, survivable and visible in the log, and it matches what + // the non-blendable-format arm above already does. + if (!m_dualSrcBlendFeatureEnabled && (IsDualSourceBlendFactor(srcRGB) || IsDualSourceBlendFactor(dstRGB) || IsDualSourceBlendFactor(srcAlpha) || IsDualSourceBlendFactor(dstAlpha))) { - THROW_EXCEPTION( - "Dual-source blending (GL_SRC1_* blend factor) was used on color attachment " + - std::to_string(i) + - ", but the Vulkan device does not support the dualSrcBlend feature (see the " - "dualSrcBlend row in the driver POST). No fallback exists; the draw cannot proceed."); + MGLOG_E_ONCE( + "GetOrCreatePipeline: dual-source blending (GL_SRC1_* blend factor) was requested on " + "color attachment %u, but the Vulkan device does not support the dualSrcBlend feature " + "(see the dualSrcBlend row in the driver POST). Blending is DECLINED on that " + "attachment - the fragment's first output is written unblended and the second source " + "is dropped (program=%u)", + i, program.GetExternalIndex()); + effectiveBlendEnabled = false; + srcRGB = BlendFactor::One; + dstRGB = BlendFactor::Zero; + srcAlpha = BlendFactor::One; + dstAlpha = BlendFactor::Zero; } payload.colorBlendAttachments[i] = MakeColorBlendAttachmentState( effectiveBlendEnabled, diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 02ebc72c..1075657e 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -115,6 +115,7 @@ add_executable(MobileGLIntegrationTest Scenarios/IntegerBorderColorScenario.cpp Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp + Scenarios/DualSourceBlendScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.cpp new file mode 100644 index 00000000..7777bd70 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.cpp @@ -0,0 +1,203 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DualSourceBlendScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A DUAL-SOURCE BLEND DRAW HAS TO SURVIVE ON EVERY DRIVER. +// +// GL_SRC1_COLOR / GL_ONE_MINUS_SRC1_COLOR / GL_SRC1_ALPHA / GL_ONE_MINUS_SRC1_ALPHA +// (ARB_blend_func_extended, core since 3.3) need a backend capability that not every device has: +// GL_EXT_blend_func_extended on the ES driver, or the dualSrcBlend device feature on Vulkan. When +// the capability IS there both backends translate the factors properly, and that has always +// worked. When it is NOT, both backends used to THROW_EXCEPTION at draw time - and +// MG_Util/Types.h's THROW_EXCEPTION is a plain `throw`, with no catch anywhere in MG_Impl or +// MG_Backend, so the exception unwound out through the C GL ABI and killed the process. An +// application asking for a blend factor the device cannot do is a picture problem, never a reason +// to take the process down. +// +// Both are now a DECLINE: the attachment is drawn with blending off and neutral One/Zero factors, +// and the loss is logged once. So a dual-source draw has exactly two defined outcomes, and this +// scenario pins that it lands on one of them and never on a crash: +// +// capability present - src0 * src1 + dst * (1 - src1) +// capability absent - src0, written straight through +// +// On the CI runners (llvmpipe / lavapipe) both capabilities are normally present, so what CI +// exercises here is the working path plus the fact that the whole sequence is crash-free; the +// decline arm is what the same code does on a device without the capability, and it is asserted +// by value rather than assumed. +// +// The Vulkan half has a second edge the last case covers: the dual-source VUIDs +// (VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 and its three siblings) +// forbid a VK_BLEND_FACTOR_SRC1_* anywhere in VkPipelineColorBlendAttachmentState without the +// feature, whatever blendEnable says - so leaving the factors in place while clearing the enable +// would still be invalid pipeline state. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kExtent = 16; + + constexpr const char* kVertexSource = R"(#version 330 core +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break; + case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break; + } +} +)"; + + // Two outputs on the SAME location, indices 0 and 1: the shader-side spelling of + // dual-source output (GLSL 3.30 4.4.2, the `index` layout qualifier). No + // glBindFragDataLocationIndexed needed, which keeps the program buildable through the + // harness's compile-and-link helper. + constexpr const char* kDualSourceFragmentSource = R"(#version 330 core +uniform vec4 uSrc0; +uniform vec4 uSrc1; +layout(location = 0, index = 0) out vec4 fragColor0; +layout(location = 0, index = 1) out vec4 fragColor1; +void main() +{ + fragColor0 = uSrc0; + fragColor1 = uSrc1; +} +)"; + + class DualSourceBlendScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + glGenVertexArrays(1, &m_vao); + glGenRenderbuffers(1, &m_renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kExtent, kExtent); + glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + std::string error; + m_program = CompileProgram(kVertexSource, kDualSourceFragmentSource, &error); + m_programError = error; + glViewport(0, 0, kExtent, kExtent); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) { + } + } + + void TearDown() override { + if (!Ready()) return; + glDisable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ZERO); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo); + if (m_renderbuffer != 0) glDeleteRenderbuffers(1, &m_renderbuffer); + if (m_program != 0) glDeleteProgram(m_program); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + } + + void Draw(float src0, float src1) { + glUseProgram(m_program); + glUniform4f(glGetUniformLocation(m_program, "uSrc0"), src0, src0, src0, 1.0f); + glUniform4f(glGetUniformLocation(m_program, "uSrc1"), src1, src1, src1, 1.0f); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glUseProgram(0); + } + + GLuint m_renderbuffer = 0; + GLuint m_fbo = 0; + GLuint m_vao = 0; + unsigned int m_program = 0; + std::string m_programError; + }; + + } // namespace + + // The whole point of the scenario: this sequence used to be a process kill on any device + // without the capability, and it has to be a picture either way. + // + // dst is black, src0 is white and src1 is mid-grey, with SRC1_COLOR / ONE_MINUS_SRC1_COLOR. + // blended = 1.0 * 0.5 + 0.0 * 0.5 = 0.5 -> ~128 + // declined = 1.0 -> 255 + // Anything else means the factors were mistranslated rather than either honoured or declined. + TEST_F(DualSourceBlendScenario, DualSourceBlendDrawProducesOneOfTheTwoDefinedResults) { + if (!Ready()) GTEST_SKIP(); + if (m_program == 0) { + GTEST_SKIP() << "this driver cannot build a dual-source fragment shader: " << m_programError; + } + + glDisable(GL_BLEND); + Draw(/*src0=*/0.0f, /*src1=*/0.0f); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); + EXPECT_EQ(FirstGLError(), 0u) << "glBlendFunc must accept the GL_SRC1_* factors - they are core since 3.3"; + Draw(/*src0=*/1.0f, /*src1=*/0.5f); + glFinish(); + glDisable(GL_BLEND); + EXPECT_EQ(FirstGLError(), 0u) << "the dual-source draw left a GL error behind"; + + const Image image = ReadPixels(kExtent, kExtent); + ASSERT_FALSE(image.Empty()); + const Rgba8 centre = image.At(kExtent / 2, kExtent / 2); + const int red = static_cast(centre.r); + const bool blended = red > 100 && red < 160; + const bool declined = red > 245; + EXPECT_TRUE(blended || declined) + << "got " << centre << ", which is neither the dual-source blend (~128) nor the declined " + << "straight-through source (255) - the SRC1 factors were mistranslated"; + Gl().EndFrame(); + } + + // The same factors with blending DISABLED. Nothing may blend, and on the Vulkan side nothing + // may reach VkPipelineColorBlendAttachmentState carrying a VK_BLEND_FACTOR_SRC1_* on a device + // without dualSrcBlend - the VUIDs bind to the struct, not to blendEnable. The picture is the + // source either way, so this case is really "no crash, no error, no surprise". + TEST_F(DualSourceBlendScenario, DualSourceFactorsWithBlendingDisabledJustWriteTheSource) { + if (!Ready()) GTEST_SKIP(); + if (m_program == 0) { + GTEST_SKIP() << "this driver cannot build a dual-source fragment shader: " << m_programError; + } + + glDisable(GL_BLEND); + Draw(/*src0=*/0.0f, /*src1=*/0.0f); + + glBlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA); + Draw(/*src0=*/1.0f, /*src1=*/0.25f); + glFinish(); + EXPECT_EQ(FirstGLError(), 0u) << "a draw with SRC1 factors and blending off left a GL error behind"; + + const Image image = ReadPixels(kExtent, kExtent); + ASSERT_FALSE(image.Empty()); + const Rgba8 centre = image.At(kExtent / 2, kExtent / 2); + EXPECT_GT(static_cast(centre.r), 245) + << "got " << centre << ": blending is disabled, so the source has to be written straight through"; + Gl().EndFrame(); + } + +} // namespace MGITest diff --git a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp index 9ddf20e8..10d90a9f 100644 --- a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp +++ b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp @@ -1034,14 +1034,69 @@ namespace { if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a}; } + // What the blend block of SyncRenderState pushed. Enough to answer the two questions the + // dual-source cases ask: is blending on for a draw buffer, and which factor enums reached + // the driver. + struct RecordedBlend { + Bool enabled = false; + Bool enableSeen = false; + Bool factorsSeen = false; + GLenum srcRGB = 0, dstRGB = 0, srcAlpha = 0, dstAlpha = 0; + }; + RecordedBlend g_driverBlend[kRecordedDrawBuffers]; + + void ResetRecordedBlend() { + for (auto& recorded : g_driverBlend) recorded = {}; + } + + void RecordBlendEnable(Bool enabled) { + for (auto& recorded : g_driverBlend) { + recorded.enabled = enabled; + recorded.enableSeen = true; + } + } + + void RecordBlendFactors(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) { + for (auto& recorded : g_driverBlend) { + recorded.factorsSeen = true; + recorded.srcRGB = srcRGB; + recorded.dstRGB = dstRGB; + recorded.srcAlpha = srcAlpha; + recorded.dstAlpha = dstAlpha; + } + } + void StubViewport(GLint, GLint, GLsizei, GLsizei) {} void StubScissor(GLint, GLint, GLsizei, GLsizei) {} - void StubEnable(GLenum) {} - void StubDisable(GLenum) {} - void StubEnablei(GLenum, GLuint) {} - void StubDisablei(GLenum, GLuint) {} - void StubBlendFuncSeparate(GLenum, GLenum, GLenum, GLenum) {} - void StubBlendFuncSeparatei(GLuint, GLenum, GLenum, GLenum, GLenum) {} + void StubEnable(GLenum cap) { + if (cap == GL_BLEND) RecordBlendEnable(true); + } + void StubDisable(GLenum cap) { + if (cap == GL_BLEND) RecordBlendEnable(false); + } + void StubEnablei(GLenum cap, GLuint index) { + if (cap == GL_BLEND && index < kRecordedDrawBuffers) { + g_driverBlend[index].enabled = true; + g_driverBlend[index].enableSeen = true; + } + } + void StubDisablei(GLenum cap, GLuint index) { + if (cap == GL_BLEND && index < kRecordedDrawBuffers) { + g_driverBlend[index].enabled = false; + g_driverBlend[index].enableSeen = true; + } + } + void StubBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) { + RecordBlendFactors(srcRGB, dstRGB, srcAlpha, dstAlpha); + } + void StubBlendFuncSeparatei(GLuint index, GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha) { + if (index >= kRecordedDrawBuffers) return; + g_driverBlend[index].factorsSeen = true; + g_driverBlend[index].srcRGB = srcRGB; + g_driverBlend[index].dstRGB = dstRGB; + g_driverBlend[index].srcAlpha = srcAlpha; + g_driverBlend[index].dstAlpha = dstAlpha; + } void StubBlendEquationSeparate(GLenum, GLenum) {} void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {} void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {} @@ -1066,7 +1121,9 @@ namespace { // into a driver that this process never made current. class ScopedRenderStateDriverStubs { public: - ScopedRenderStateDriverStubs(): + // dualSourceBlendSupported models GL_EXT_blend_func_extended on the ES driver, which is + // the one capability in here that a real device is commonly WITHOUT. + explicit ScopedRenderStateDriverStubs(Bool dualSourceBlendSupported = true): m_funcs(MG_Backend::DirectGLES::g_GLESFuncs), m_caps(MG_Backend::DirectGLES::g_GLESCapabilities) { auto& gl = MG_Backend::DirectGLES::g_GLESFuncs; gl = MG_External::GLESFunctionsTable{}; @@ -1102,9 +1159,10 @@ namespace { caps.SupportsIndexedColorMask = true; caps.SupportsSrgbWriteControl = false; caps.SupportsPolygonMode = false; - caps.SupportsDualSourceBlend = true; + caps.SupportsDualSourceBlend = dualSourceBlendSupported; ResetRecordedColorMasks(); + ResetRecordedBlend(); // The viewport and scissor blocks fall back to querying the surface size when the // frontend's rectangle is degenerate, and there is no surface in this process. MG_Impl::GLImpl::Viewport(0, 0, 4, 4); @@ -1119,6 +1177,11 @@ namespace { // The shadow now describes pushes that went to the stubs, not to any driver. MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState(); MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + // Blend state is per-CONTEXT and the context outlives the fixture, so a case that + // enabled blending or asked for an exotic factor has to put it back or every later + // case in this binary inherits it. + MG_Impl::GLImpl::Disable(GL_BLEND); + MG_Impl::GLImpl::BlendFunc(GL_ONE, GL_ZERO); } private: @@ -1253,6 +1316,72 @@ TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffe EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); } +// --- Dual-source blending without GL_EXT_blend_func_extended ------------------------------------ +// +// GL_SRC1_* blend factors are core GL since 3.3, GLES core has nothing equivalent, and the ES +// driver may or may not carry GL_EXT_blend_func_extended. When it does, the factors translate and +// blend properly - the positive case below. When it does not, the blend block used to +// THROW_EXCEPTION, which is a plain `throw` (MG_Util/Types.h) with no catch anywhere in MG_Impl or +// MG_Backend, so it unwound out through the C GL ABI and killed the process over one unsupported +// blend factor. It now DECLINES: the draw buffer is pushed with blending off and neutral One/Zero +// factors, and the loss is logged once. +// +// Both halves are asserted at the seam that matters - what the ES driver is actually handed - +// because a GL_SRC1_* enum reaching a driver without the extension is the other failure mode: the +// driver answers GL_INVALID_ENUM, keeps whatever factors were set before, and mis-blends silently. + +TEST_F(FramebufferTest, DualSourceBlendFactorsReachTheDriverWhenTheExtensionIsThere) { + ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/true); + + MG_Impl::GLImpl::Enable(GL_BLEND); + MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); + ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "GL_SRC1_* is core since 3.3; glBlendFunc must take it"; + ResetRecordedBlend(); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + + ASSERT_TRUE(g_driverBlend[0].factorsSeen); + EXPECT_TRUE(g_driverBlend[0].enabled) << "nothing may decline a blend the driver can do"; + EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast(GL_SRC1_COLOR)); + EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast(GL_ONE_MINUS_SRC1_COLOR)); + EXPECT_EQ(g_driverBlend[0].srcAlpha, static_cast(GL_SRC1_COLOR)); + EXPECT_EQ(g_driverBlend[0].dstAlpha, static_cast(GL_ONE_MINUS_SRC1_COLOR)); +} + +TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensionIsMissing) { + ScopedRenderStateDriverStubs driver(/*dualSourceBlendSupported=*/false); + + MG_Impl::GLImpl::Enable(GL_BLEND); + MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA); + ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) + << "the FRONTEND accepts the factor whatever the driver can do - the decline is a backend decision"; + ResetRecordedBlend(); + + // The whole point: this used to be `throw std::runtime_error` straight through the GL ABI. + ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false)); + + ASSERT_TRUE(g_driverBlend[0].enableSeen) << "the blend enable still has to be pushed"; + EXPECT_FALSE(g_driverBlend[0].enabled) << "a blend the driver cannot do is declined, not attempted"; + for (Uint i = 0; i < kRecordedDrawBuffers; ++i) { + EXPECT_NE(g_driverBlend[i].srcRGB, static_cast(GL_SRC1_ALPHA)) + << "draw buffer " << i << ": no GL_SRC1_* enum may reach a driver without the extension"; + EXPECT_NE(g_driverBlend[i].dstRGB, static_cast(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i; + EXPECT_NE(g_driverBlend[i].srcAlpha, static_cast(GL_SRC1_ALPHA)) << "draw buffer " << i; + EXPECT_NE(g_driverBlend[i].dstAlpha, static_cast(GL_ONE_MINUS_SRC1_ALPHA)) << "draw buffer " << i; + } + + // The decline is scoped to the offending factor, not to blending as a whole: an ordinary + // blend on the same driver still goes through, and the SAME sync that declined the first one + // is what has to push it. + MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + ResetRecordedBlend(); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + ASSERT_TRUE(g_driverBlend[0].factorsSeen); + EXPECT_TRUE(g_driverBlend[0].enabled); + EXPECT_EQ(g_driverBlend[0].srcRGB, static_cast(GL_SRC_ALPHA)); + EXPECT_EQ(g_driverBlend[0].dstRGB, static_cast(GL_ONE_MINUS_SRC_ALPHA)); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + // --- glFramebufferTexture error conditions (GL 4.6 core 9.2.8) --------------------------------- // // Four of them were missing from the bound-target path while its DSA sibling