From d8d75300115fb5fa21942e040e6c0fc7b9b754e5 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Sun, 9 Aug 2026 15:28:34 -0400 Subject: [PATCH] [Fix, Test] (MG_Backend/DirectGLES, MG_Util, MG_Impl): widen three-channel render targets wherever the driver refuses them Complementary Reimagined would not load through Espryt on Mali: Iris got GL_FRAMEBUFFER_UNSUPPORTED building its composite framebuffer, because colortex1 is RGB8_SNORM and colortex2 is RGB16F - three-channel formats that no real ES driver can render to (EXT_render_snorm covers R/RG/RGBA only, and the float extensions exclude the RGB forms). The frontend's probe cache diagnosed this correctly and then had nothing to offer: the NoThreeChannelRenderTarget widening machinery existed but was gated to multisample targets alone. llvmpipe turns out to refuse most of the same attachments - CI retrace stayed green only because a replay never branches on glCheckFramebufferStatus - so this was never a desktop-vs- device split, just an unlit path. The widening now applies to every color-attachable image, renderbuffers included, riding the driver-probe branch so the native format is still tried first and substituted only on refusal. One ThreeChannelWidening table owns the widened (internalformat, format, type) triple per source format - the previous per-case branches disagreed with each other and could emit an unuploadable (RGBA16F, GL_RGB, GL_BYTE) combination or widen into another three-channel format the driver refuses just the same. Uploads repack three-component client data to four with the format's own one in the alpha channel (127 is not 1 for RGB8I - the integer arms carry integer ones); readback drops the synthetic alpha, derived from the actual image being read, not the bound framebuffer, so glGetTexImage through a scratch FBO cannot be confused by an unrelated widened attachment. Stored alpha on a widened attachment is now an invariant 1.0 rather than an accident: the color-mask sync clears the alpha bit per draw buffer (glColorMaski for MRT mixes), and clears route through glClearBufferfv with alpha substituted on widened slots only - scissored clears inherit the discipline for free, integer color buffers keep their explicit integer-clear path, and glGet still answers the application's own mask. GL_DST_ALPHA blending, blits and readback therefore all see 1.0 without further interception. DriverPost grows the rows this bug earned: EXT_color_buffer_float detection (previously unreferenced anywhere) with a FAIL row when absent, the missing EXT_render_snorm row, and a three-channel- attachment row that reports one representative per widening class - graded so a half-float-only driver warns about the 32-bit float gap instead of being declared unsupported. Gates: 606/606 unit at default and with the async kill switch; full retrace, both backends - the complementary fixtures now run with the widening ACTIVE on llvmpipe and pass with a slightly better SSIM than before; ext caselist DirectGLES holds 3914/4867 with zero set drift while 54 cases move from NotSupported to genuinely passing; on the Mali-G77 device, Complementary Reimagined builds its pipeline and renders in-world through Espryt (md5-verified build), BSL still green. A new ThreeChannelAttachmentScenario pins the frontend answer - COMPLETE where it used to say UNSUPPORTED - on the real driver. --- .../DirectGLES/BackendObject_DirectGLES.cpp | 104 +++-- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 262 ++++++++++-- MobileGL/MG_Backend/DirectGLES/DirectGLES.h | 20 + MobileGL/MG_Backend/DirectGLES/Managers.cpp | 332 ++++++++++++++- MobileGL/MG_Backend/DirectGLES/Managers.h | 80 ++++ MobileGL/MG_Backend/DirectGLES/Utils.cpp | 105 +++-- MobileGL/MG_Backend/DirectGLES/Utils.h | 20 +- .../GLImpl/Framebuffer/GL_Framebuffer.cpp | 8 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../ThreeChannelAttachmentScenario.cpp | 300 +++++++++++++ .../MG_Test/Framebuffer/FramebufferTest.cpp | 394 ++++++++++++++++++ MobileGL/MG_Test/Texture/TextureTest.cpp | 291 +++++++++++++ .../MG_Util/BackendLoaders/OpenGL/Loader.cpp | 6 + .../MG_Util/BackendLoaders/OpenGL/Loader.h | 10 + MobileGL/MG_Util/SelfTest/DriverPost.cpp | 146 +++++++ .../Texture/TextureFormatProcessor.cpp | 119 +++++- .../MG_Util/Texture/TextureFormatProcessor.h | 11 +- 17 files changed, 2066 insertions(+), 143 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index 4e3f7e3b..f4b21ae4 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -210,7 +210,7 @@ namespace MobileGL::MG_Backend::DirectGLES { reasons.push_back("GL_DEPTH_COMPONENT32 native probe failed on OpenGL ES"); } if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { - reasons.push_back("no three-channel multisample storage format on OpenGL ES"); + reasons.push_back("no colour-renderable three-channel format on OpenGL ES"); } if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { reasons.push_back("EXT_render_snorm not supported"); @@ -553,26 +553,60 @@ namespace MobileGL::MG_Backend::DirectGLES { for (SizeT targetIndex = 0; targetIndex < kFormatCapabilityTextureTargetCount; ++targetIndex) { const auto target = static_cast(targetIndex); - // A multisample texture can only ever be rendered into, so its storage format - // has to stay colour-renderable; the ordinary fallback for a three-channel - // format is a three-channel one, which ES accepts as a texture but rejects as - // multisample storage. Recompute the fallback per target so those formats get - // widened here and nowhere else. - Flags targetOptions; - if (IsGLESProbeMultisampleTarget(target)) { - targetOptions |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; - if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) { - targetOptions |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; - } - } + // Colour-attachable targets need a colour-renderable fallback; the ordinary + // fallback for a three-channel format is another three-channel one, which ES + // accepts as a texture but never as an attachment. Recompute the fallback per + // target so those formats get widened where the target demands it. + const Flags renderTargetOptions = + TextureImpl::GetRenderTargetNormalizeOptions(capabilities, targetIndex); + // Multisample storage has no three-channel form on ES at all, so its widening + // is unconditional and skips the native probe (which cannot succeed). Every + // other target keeps the widening on the DRIVER branch, behind the native + // probe: `shouldProbeFallback = !nativeCreated || !nativeRenderable` below is + // what makes the substitution conditional on the driver actually refusing, so + // a driver that does render to a three-channel image keeps allocating it byte + // for byte. That is a per-format runtime answer, NOT a desktop-vs-device + // split: llvmpipe renders to GL_RGB16F but refuses GL_RGB8_SNORM, GL_SRGB8, + // GL_RGB32F and the RGB integer formats, so the CI driver widens those eight + // too. Re-run the retrace fixtures and the glcts suites on any change here. + const Bool widenUnconditionally = IsGLESProbeMultisampleTarget(target); GLESProbeFormatInfo fallbackInfo = outerFallbackInfo; Bool hasForcedFallback = outerHasForcedFallback; - if (targetOptions) { - hasForcedFallback = BuildFallbackProbeFormatInfo( - requestedInternalFormat, forcedOptions | targetOptions, true, fallbackInfo); - if (!hasForcedFallback) { - BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | targetOptions, false, + if (renderTargetOptions) { + // Folded into the forced options only when a forced fallback already + // applies, so the render-target bits never *create* one: ANGLE's forced + // GL_RGB8_SNORM -> GL_RGB16F is still three-channel and still needs + // widening, but a non-ANGLE driver must not lose its native probe. + const Flags forcedProbeOptions = + (outerHasForcedFallback || widenUnconditionally) ? forcedOptions | renderTargetOptions + : forcedOptions; + hasForcedFallback = + BuildFallbackProbeFormatInfo(requestedInternalFormat, forcedProbeOptions, true, fallbackInfo); + if (!hasForcedFallback) { + BuildFallbackProbeFormatInfo(requestedInternalFormat, + driverOptions | renderTargetOptions, false, fallbackInfo); + } + // HONEST STATUS OF THE FORCED PATH. A forced fallback is only ever built + // for ANGLE (GetForcedPixelFormatNormalizeOptions returns nothing for any + // other renderer), and it SKIPS the native probe entirely - the widened + // format is asserted rather than measured on this device. That assertion + // is validated on exactly one configuration, the android-angle retrace + // golden; it is NOT covered by the headless llvmpipe suites, which take + // the driver branch below and prove nothing about ANGLE's answers. So log + // the choice at INFO rather than the usual MGLOG_D caveat: on any other + // ANGLE device the device report is the only evidence there is of which + // storage format the image really got. Once per format on the ordinary 2D + // target - repeating it for all ten targets would bury the report. + if (hasForcedFallback && target == TextureTarget::Texture2D && + (MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions( + requestedInternalFormat, renderTargetOptions) & + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) { + MGLOG_I("Three-channel widening (FORCED path, no native probe): %s stored as %s. " + "Reason: %s. Device-validated on the android-angle golden only.", + MG_Util::ConvertTextureInternalFormatToString(logicalFormat).c_str(), + ConvertFallbackInternalFormatToString(fallbackInfo.InternalFormat).c_str(), + fallbackInfo.Reason.c_str()); } } @@ -618,8 +652,26 @@ namespace MobileGL::MG_Backend::DirectGLES { } const SizeT renderbufferTargetIndex = GetRenderbufferFormatCapabilityTargetIndex(); - Bool shouldProbeFallbackRenderbuffer = outerHasForcedFallback; - if (!outerHasForcedFallback) { + // A renderbuffer exists only to be attached, so it needs the same three-channel + // widening the colour-attachable texture targets get - and on the same terms: the + // native storage is probed first, so a driver that renders to it keeps it. + const Flags renderbufferOptions = + TextureImpl::GetRenderTargetNormalizeOptions(capabilities, renderbufferTargetIndex); + GLESProbeFormatInfo renderbufferFallbackInfo = outerFallbackInfo; + Bool renderbufferHasForcedFallback = outerHasForcedFallback; + if (renderbufferOptions) { + const Flags forcedProbeOptions = + outerHasForcedFallback ? forcedOptions | renderbufferOptions : forcedOptions; + renderbufferHasForcedFallback = BuildFallbackProbeFormatInfo( + requestedInternalFormat, forcedProbeOptions, true, renderbufferFallbackInfo); + if (!renderbufferHasForcedFallback) { + BuildFallbackProbeFormatInfo(requestedInternalFormat, driverOptions | renderbufferOptions, + false, renderbufferFallbackInfo); + } + } + + Bool shouldProbeFallbackRenderbuffer = renderbufferHasForcedFallback; + if (!renderbufferHasForcedFallback) { const Bool nativeRenderbufferComplete = ProbeRenderbuffer(gl, nativeInfo.InternalFormat, logicalFormat, false, 1); if (nativeRenderbufferComplete) { @@ -633,16 +685,16 @@ namespace MobileGL::MG_Backend::DirectGLES { shouldProbeFallbackRenderbuffer = true; } } - if (shouldProbeFallbackRenderbuffer && outerFallbackInfo.InternalFormat != GL_UNKNOWN_MGL && - ProbeRenderbuffer(gl, outerFallbackInfo.InternalFormat, logicalFormat, false, 1)) { + if (shouldProbeFallbackRenderbuffer && renderbufferFallbackInfo.InternalFormat != GL_UNKNOWN_MGL && + ProbeRenderbuffer(gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, false, 1)) { if (AddCaveatFormatCaps(cache, renderbufferTargetIndex, formatIndex, GetRenderbufferFeatureCaps(logicalFormat))) { - LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, outerFallbackInfo); + LogGLESFormatCaveat(logicalFormat, renderbufferTargetIndex, renderbufferFallbackInfo); } const Int maxSamples = - GetGLESFormatMaxSamples(capabilities, logicalFormat, outerFallbackInfo.ImageFormat); - cache.SampleCounts[renderbufferTargetIndex][formatIndex] = - ProbeRenderbufferSampleCounts(gl, outerFallbackInfo.InternalFormat, logicalFormat, maxSamples); + GetGLESFormatMaxSamples(capabilities, logicalFormat, renderbufferFallbackInfo.ImageFormat); + cache.SampleCounts[renderbufferTargetIndex][formatIndex] = ProbeRenderbufferSampleCounts( + gl, renderbufferFallbackInfo.InternalFormat, logicalFormat, maxSamples); } } } diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index bb52bf76..18f69d40 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1401,7 +1401,14 @@ namespace MobileGL::MG_Backend::DirectGLES { } if (currentFBO == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { - // Default FBO, nothing to sync + // Default FBO, nothing to sync - except the widened-attachment mask, which is + // only ever WRITTEN by SyncToBackend and would otherwise still describe the + // user FBO that was draw-bound before. The window surface is a real RGBA + // buffer, so nothing here is ever widened. + if (target == FramebufferTarget::Draw) { + g_alphaWidenedDrawBufferMask = 0; + g_integerColorDrawBufferMask = 0; + } StampSyncedFBO(target, slotVersion, objectVersion, currentPtr); continue; } @@ -1459,20 +1466,35 @@ namespace MobileGL::MG_Backend::DirectGLES { // direct_state_access.renderbuffers_storage. One unconditional push settles the whole // block rather than the one cap that happened to be noticed. static Bool g_forceFullRenderStateResync = true; + // Which draw buffers' alpha channel the colour mask last pushed to the driver had forced + // OFF - i.e. the value of `appliedWidenMask` in the last SyncRenderState that reached the + // colour-mask block. NOT derivable from the frontend parameter block: it depends on the + // bound DRAW framebuffer's attachment formats and on whether the caller is a draw or a + // clear, neither of which bumps the frontend render-state version. Without it a + // 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; void InvalidateSyncedRenderState() { g_forceFullRenderStateResync = true; g_hasSyncedRenderState = false; g_syncedBackendViewport = IntVec4(-1, -1, -1, -1); g_syncedBackendScissorBox = IntVec4(-1, -1, -1, -1); } - void SyncRenderState() { + void SyncRenderState(Bool forColorClear) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); const Bool forceFullPush = g_forceFullRenderStateResync; g_forceFullRenderStateResync = false; - if (!forceFullPush && g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) { + // The alpha discipline for widened colour attachments (see the header comment on + // SyncRenderState): a DRAW must not be able to move the stored alpha off 1.0, a CLEAR + // is what puts it there. So the draw path masks alpha off on every widened draw + // buffer and the clear path masks nothing. + const Uint32 appliedWidenMask = forColorClear ? 0u : FramebufferImpl::g_alphaWidenedDrawBufferMask; + const Bool colorMaskWidenDirty = appliedWidenMask != g_syncedColorMaskAlphaWidenMask; + if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState && + currentRenderStateVersion == g_syncedRenderStateVersion) { return; } @@ -1778,23 +1800,42 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - if (tailSpanDirty) { // Color mask. Uniform masks use the non-indexed glColorMask (works everywhere); divergent - // per-draw-buffer masks use the indexed glColorMaski when draw_buffers_indexed is - // available, otherwise fall back to broadcasting draw buffer 0. Mirrors the blend block. + if (tailSpanDirty || colorMaskWidenDirty) { // Color mask. Uniform masks use the non-indexed glColorMask + // (works everywhere); divergent per-draw-buffer masks use the indexed glColorMaski when + // draw_buffers_indexed is available, otherwise fall back to broadcasting draw buffer 0. + // Mirrors the blend block. using FBO = MG_State::GLState::FramebufferObject; const auto& targetMasks = parameters.ColorMasks; const auto& syncedMasks = g_syncedRenderStateParameters.ColorMasks; - Bool anyDirty = forceFullPush; + // What the DRIVER is told for draw buffer i. Identical to the application's mask + // except on a widened attachment during a draw, where alpha is forced off; the + // frontend's own array is never written, so glGet(GL_COLOR_WRITEMASK) keeps + // answering with the application's value. + const auto driverMask = [&](Uint i) -> BoolVec4 { + BoolVec4 m = targetMasks[i]; + if (i < 32 && (appliedWidenMask & (1u << i)) != 0) { + m.w() = false; + } + return m; + }; + + Bool anyDirty = forceFullPush || colorMaskWidenDirty; Bool allSame = true; + const BoolVec4 driverMask0 = driverMask(0); for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { if (targetMasks[i] != syncedMasks[i]) anyDirty = true; - if (i > 0 && targetMasks[i] != targetMasks[0]) allSame = false; + if (i > 0 && driverMask(i) != driverMask0) allSame = false; } if (anyDirty) { if (allSame || !g_GLESCapabilities.SupportsIndexedColorMask) { - const BoolVec4& m = targetMasks[0]; + // Without draw_buffers_indexed there is only one mask for the whole + // framebuffer, so a widened draw buffer 0 costs every other buffer its + // alpha writes. ES 3.2 makes glColorMaski core and ES 3.1 has it as + // EXT/OES; the only devices that reach this line are ES 3.0-class, where + // MRT with a mixed widened/native colour attachment set is already rare. + const BoolVec4& m = driverMask0; g_GLESFuncs.glColorMask(ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()), ToGLBoolean(m.w())); } else { @@ -1802,14 +1843,18 @@ namespace MobileGL::MG_Backend::DirectGLES { : g_GLESFuncs.glColorMaskiEXT ? g_GLESFuncs.glColorMaskiEXT : g_GLESFuncs.glColorMaskiOES; for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS; ++i) { - if (forceFullPush || targetMasks[i] != syncedMasks[i]) { - const BoolVec4& m = targetMasks[i]; + // colorMaskWidenDirty forces every slot: the previous push may have + // been the non-indexed glColorMask above (which set all of them), and + // the per-slot diff below only knows about the application's array. + if (forceFullPush || colorMaskWidenDirty || targetMasks[i] != syncedMasks[i]) { + const BoolVec4 m = driverMask(i); colorMaskiFn(i, ToGLBoolean(m.x()), ToGLBoolean(m.y()), ToGLBoolean(m.z()), ToGLBoolean(m.w())); } } } } + g_syncedColorMaskAlphaWidenMask = appliedWidenMask; } if (tailSpanDirty) { // Polygon mode. GLES core has no glPolygonMode; use NV/ANGLE_polygon_mode when present. @@ -2089,6 +2134,15 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (!framebuffer || framebuffer == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { + // Same reset as SyncCurrentFBO's default-framebuffer branch: SyncToBackend is the + // only writer of the widened-attachment mask, so a path that skips it has to say so + // explicitly. It matters here because the DSA clears and glBlitFramebuffer briefly + // sync a DIFFERENT framebuffer as DRAW and then restore the application's through + // ForceBindCurrentFBO - which lands right here when that one is the default. + if (target == FramebufferTarget::Draw) { + FramebufferImpl::g_alphaWidenedDrawBufferMask = 0; + FramebufferImpl::g_integerColorDrawBufferMask = 0; + } FramebufferImpl::BindFramebufferId( target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0); return; @@ -3009,7 +3063,11 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif TextureImpl::SyncNeccessaryTextures(); FramebufferImpl::SyncCurrentFBO(); - RenderStateImpl::SyncRenderState(); + // A colour clear is exactly the operation that is allowed to write a widened + // attachment's alpha - it is what puts the 1.0 there that every later draw is masked + // away from. SyncCurrentFBO ran first, so g_alphaWidenedDrawBufferMask already describes + // the framebuffer this clear will land on. + RenderStateImpl::SyncRenderState(/*forColorClear=*/(mask & GL_COLOR_BUFFER_BIT) != 0); BindCurrentFBO(FramebufferTarget::Draw); @@ -3047,10 +3105,26 @@ namespace MobileGL::MG_Backend::DirectGLES { const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor; const Bool outOfRange = cc.x() < 0.f || cc.x() > 1.f || cc.y() < 0.f || cc.y() > 1.f || cc.z() < 0.f || cc.z() > 1.f || cc.w() < 0.f || cc.w() > 1.f; + // A widened attachment's stored alpha has to end up 1.0, and glClear applies ONE + // clear colour to every draw buffer - so a framebuffer that mixes a widened + // attachment with a native one cannot be served by doctoring glClearColor. Take the + // same per-draw-buffer glClearBufferfv route the out-of-range case already uses and + // substitute the alpha only where it belongs. Scissor and the colour write mask apply + // to glClearBufferfv exactly as they do to glClear, so a scissored clear stays + // scissored and an application that masked alpha off still gets its way (the storage + // then keeps the 1.0 an earlier clear left, which is the same answer). + // + // glClearBufferfv on an INTEGER colour buffer is GL_INVALID_OPERATION, so a + // framebuffer with one of those as a draw buffer keeps plain glClear - which ES + // leaves undefined for integer colour buffers anyway, and which an application that + // wants a defined answer must replace with glClearBufferuiv/iv (those DO substitute + // the widened alpha). The out-of-range trigger is left exactly as it was. + const Uint32 widenedDrawBuffers = FramebufferImpl::g_alphaWidenedDrawBufferMask; + const Bool widenedColorClear = + widenedDrawBuffers != 0 && FramebufferImpl::g_integerColorDrawBufferMask == 0; GLint clearDrawFbo = 0; g_GLESFuncs.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &clearDrawFbo); - if (outOfRange && clearDrawFbo != 0) { - const GLfloat value[4] = {cc.x(), cc.y(), cc.z(), cc.w()}; + if ((outOfRange || widenedColorClear) && clearDrawFbo != 0) { GLint maxDrawBuffers = 0; GLint clearedCount = 0; GLint firstDb = -1; @@ -3060,6 +3134,8 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glGetIntegerv(GL_DRAW_BUFFER0 + static_cast(i), &db); if (i == 0) firstDb = db; if (db != GL_NONE) { + const Bool widened = i < 32 && (widenedDrawBuffers & (1u << i)) != 0; + const GLfloat value[4] = {cc.x(), cc.y(), cc.z(), widened ? 1.0f : cc.w()}; g_GLESFuncs.glClearBufferfv(GL_COLOR, i, value); ++clearedCount; } @@ -5316,37 +5392,82 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glClearBufferfi(buffer, drawbuffer, depth, stencil); } + namespace { + using FramebufferImpl::SubstituteWidenedClearAlpha; + + // A colour attachment the backend widened from three channels to four has to end up + // holding alpha 1.0 - the value GL reports for a channel the application's format does + // not have - so an explicit per-buffer clear of it writes 1.0 rather than whatever the + // application passed (SubstituteWidenedClearAlpha, in Managers.h). Draws can never move + // it again: their alpha write mask is forced off, see SyncRenderState. That pairing is + // what makes GL_DST_ALPHA blending, glReadPixels and glBlitFramebuffer all see the right + // value without any of them being intercepted. + + // Whether draw buffer `drawbuffer` of the framebuffer currently bound as DRAW is such an + // attachment. Answered from the mask SyncCurrentFBO just recomputed, so it costs nothing. + Bool IsWidenedBoundDrawBuffer(GLenum buffer, GLint drawbuffer) { + return buffer == GL_COLOR && drawbuffer >= 0 && drawbuffer < 32 && + (FramebufferImpl::g_alphaWidenedDrawBufferMask & (1u << drawbuffer)) != 0; + } + + // The same question for an explicitly named framebuffer (the DSA clears), which is NOT + // the one g_alphaWidenedDrawBufferMask describes at the point these run. + Bool IsWidenedNamedDrawBuffer(const SharedPtr& framebuffer, + GLenum buffer, GLint drawbuffer) { + using FBO = MG_State::GLState::FramebufferObject; + if (buffer != GL_COLOR || !framebuffer || drawbuffer < 0 || + drawbuffer >= static_cast(FBO::MAX_DRAW_BUFFERS)) { + return false; + } + const auto frontendBuf = framebuffer->GetDrawBuffers()[static_cast(drawbuffer)]; + if (frontendBuf < FramebufferAttachmentType::Color0 || + frontendBuf > FramebufferAttachmentType::Color31) { + return false; + } + return FramebufferImpl::IsAlphaWidenedColorAttachment(framebuffer->GetAttachment(frontendBuf)); + } + } // namespace + void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value) { TextureImpl::SyncNeccessaryTextures(); FramebufferImpl::SyncCurrentFBO(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); BindCurrentFBO(FramebufferTarget::Draw); - g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value); + GLfloat widenedValue[4] = {}; + g_GLESFuncs.glClearBufferfv( + buffer, drawbuffer, + SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), 1.0f, widenedValue)); } void ClearBufferiv(GLenum buffer, GLint drawbuffer, const GLint* value) { TextureImpl::SyncNeccessaryTextures(); FramebufferImpl::SyncCurrentFBO(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); // SyncCurrentFBO early-outs for the default framebuffer, so without this // bind a user-FBO -> default-FBO switch would leave the clear landing on // the stale driver DRAW binding (the fi/fv/uiv siblings all bind too). BindCurrentFBO(FramebufferTarget::Draw); - g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value); + GLint widenedValue[4] = {}; + g_GLESFuncs.glClearBufferiv( + buffer, drawbuffer, + SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), GLint(1), widenedValue)); } void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value) { TextureImpl::SyncNeccessaryTextures(); FramebufferImpl::SyncCurrentFBO(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); BindCurrentFBO(FramebufferTarget::Draw); - g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value); + GLuint widenedValue[4] = {}; + g_GLESFuncs.glClearBufferuiv( + buffer, drawbuffer, + SubstituteWidenedClearAlpha(value, IsWidenedBoundDrawBuffer(buffer, drawbuffer), GLuint(1), widenedValue)); } void ClearNamedFramebufferfv(const SharedPtr& framebuffer, @@ -5355,9 +5476,12 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::OpenGLScopeMarker marker(__func__); #endif TextureImpl::SyncNeccessaryTextures(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); + GLfloat widenedValue[4] = {}; + value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer), 1.0f, + widenedValue); g_GLESFuncs.glClearBufferfv(buffer, drawbuffer, value); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -5389,9 +5513,12 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::OpenGLScopeMarker marker(__func__); #endif TextureImpl::SyncNeccessaryTextures(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); + GLint widenedValue[4] = {}; + value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer), + GLint(1), widenedValue); g_GLESFuncs.glClearBufferiv(buffer, drawbuffer, value); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -5406,9 +5533,12 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::OpenGLScopeMarker marker(__func__); #endif TextureImpl::SyncNeccessaryTextures(); - RenderStateImpl::SyncRenderState(); + RenderStateImpl::SyncRenderState(/*forColorClear=*/buffer == GL_COLOR); SyncAndBindFramebufferObject(framebuffer, FramebufferTarget::Draw, true); + GLuint widenedValue[4] = {}; + value = SubstituteWidenedClearAlpha(value, IsWidenedNamedDrawBuffer(framebuffer, buffer, drawbuffer), + GLuint(1), widenedValue); g_GLESFuncs.glClearBufferuiv(buffer, drawbuffer, value); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -5681,16 +5811,9 @@ namespace MobileGL::MG_Backend::DirectGLES { format == GL_RGBA_INTEGER; } - // Expands a tightly-packed narrow read (1-3 channels per texel) into the 4-channel wide RGBA - // layout ConvertWideReadbackRow expects. Missing G/B read zero; missing A reads one, encoded in - // the source component type. - static void ExpandNarrowWideRead(Vector& data, SizeT pixelCount, Int srcChannels, GLenum componentType) { - const SizeT componentSize = GetReadbackComponentSize(componentType); - if (componentSize == 0 || srcChannels <= 0 || srcChannels >= 4) { - return; - } - Uint8 zeroBits[4] = {0, 0, 0, 0}; - Uint8 oneBits[4] = {0, 0, 0, 0}; + // The bit pattern of 1.0 in a wide-read component type: what GL reports for a channel the + // attachment's format does not have. + static void FillWideReadOneBits(GLenum componentType, Uint8* oneBits) { switch (componentType) { case GL_UNSIGNED_BYTE: oneBits[0] = 0xFF; @@ -5727,6 +5850,35 @@ namespace MobileGL::MG_Backend::DirectGLES { default: break; } + } + + // Overwrites the alpha of a 4-channel wide read with the format's implied 1.0. Used for an + // attachment the backend widened from three channels to keep it colour-renderable: the storage + // has a real alpha channel holding whatever the draw wrote, but the format the application + // asked for has none, and GL reads a missing channel back as one. + static void ForceWideReadAlphaToOne(Vector& data, SizeT pixelCount, GLenum componentType) { + const SizeT componentSize = GetReadbackComponentSize(componentType); + if (componentSize == 0 || data.size() < pixelCount * 4 * componentSize) { + return; + } + Uint8 oneBits[4] = {0, 0, 0, 0}; + FillWideReadOneBits(componentType, oneBits); + for (SizeT i = 0; i < pixelCount; ++i) { + Memcpy(data.data() + (i * 4 + 3) * componentSize, oneBits, componentSize); + } + } + + // Expands a tightly-packed narrow read (1-3 channels per texel) into the 4-channel wide RGBA + // layout ConvertWideReadbackRow expects. Missing G/B read zero; missing A reads one, encoded in + // the source component type. + static void ExpandNarrowWideRead(Vector& data, SizeT pixelCount, Int srcChannels, GLenum componentType) { + const SizeT componentSize = GetReadbackComponentSize(componentType); + if (componentSize == 0 || srcChannels <= 0 || srcChannels >= 4) { + return; + } + Uint8 zeroBits[4] = {0, 0, 0, 0}; + Uint8 oneBits[4] = {0, 0, 0, 0}; + FillWideReadOneBits(componentType, oneBits); Vector expanded(pixelCount * 4 * componentSize); for (SizeT i = 0; i < pixelCount; ++i) { @@ -5771,9 +5923,16 @@ namespace MobileGL::MG_Backend::DirectGLES { // Reads the current READ framebuffer as wide RGBA(_INTEGER) and repacks the pixels into the client's // (format, type) layout. Returns false when the combination is not convertible (the caller keeps its // "not implemented" skip); returns true when the request was handled, even if it degraded to a logged no-op. + // `forceOpaqueAlpha`: the source image is a three-channel format the backend widened to four to + // keep it colour-renderable, so its alpha channel holds whatever the draw wrote and has to be + // answered with the 1.0 the application's format implies. Passed in rather than derived here: + // glReadPixels reads the bound READ framebuffer, but glGetTexImage reads a texture through a + // scratch framebuffer, so the frontend's READ binding describes a different image entirely - + // consulting it there would both miss real widenings and corrupt readbacks of ordinary + // textures taken while some unrelated widened attachment happened to be bound. static Bool ReadPixelsViaFormatConversion(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, - GLenum type, void* pixels, Bool honorPackImageParams = false, - Bool applyFixedPointReadClamp = true) { + GLenum type, void* pixels, Bool honorPackImageParams, + Bool applyFixedPointReadClamp, Bool forceOpaqueAlpha) { ReadbackChannelMapping mapping{}; if (!GetReadbackChannelMapping(format, mapping)) { return false; @@ -5900,6 +6059,14 @@ namespace MobileGL::MG_Backend::DirectGLES { ExpandNarrowWideRead(wide, static_cast(width) * static_cast(height), readChannels, wideType); } + // Undo the three-channel widening (see the parameter's comment). Deliberately not gated on + // applyFixedPointReadClamp: that flag implements GL_CLAMP_READ_COLOR, which glGetTexImage + // is exempt from, whereas "a format without alpha reads as 1.0" is the format's own + // semantics and applies to every read. + if (forceOpaqueAlpha) { + ForceWideReadAlphaToOne(wide, static_cast(width) * static_cast(height), wideType); + } + // GL clamps a read from a fixed-point colour buffer to [0,1] (GL_CLAMP_READ_COLOR // defaults to GL_FIXED_ONLY). Formats the backend substitutes with a floating-point // one keep the out-of-range value the app stored, so apply the clamp here - a @@ -6058,11 +6225,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // the driver accepts for the current attachment. GL_PACK_SWAP_BYTES has no ES equivalent, so // it always takes the conversion path (which swaps on the CPU). const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes; - const Bool nativeFastPair = !packSwapBytes && + // The read buffer is what glReadPixels reads, so the frontend's READ binding is exactly + // the right thing to ask here. + const Bool forceOpaqueAlpha = FramebufferImpl::IsAlphaWidenedFallbackReadAttachment(); + // An attachment widened from three channels to stay colour-renderable also has to leave + // the fast pair: only the conversion path knows to answer its alpha with the 1.0 the + // application's format implies instead of whatever the draw wrote into the added channel. + const Bool nativeFastPair = !packSwapBytes && !forceOpaqueAlpha && ((format == GL_RGBA && type == GL_UNSIGNED_BYTE) || (format == GL_RGBA_INTEGER && (type == GL_UNSIGNED_INT || type == GL_INT))); if (convertible && !nativeFastPair) { - if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels)) { + if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels, + /*honorPackImageParams=*/false, /*applyFixedPointReadClamp=*/true, + forceOpaqueAlpha)) { MGLOG_D("ReadPixels: finished via client-format conversion"); return; } @@ -6121,7 +6296,9 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("ReadPixels: native read of %s/%s failed (%s), retrying via client-format conversion", MG_Util::ConvertGLEnumToString(format).c_str(), MG_Util::ConvertGLEnumToString(type).c_str(), MG_Util::ConvertGLEnumToString(nativeReadError).c_str()); - if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels)) { + if (ReadPixelsViaFormatConversion(x, y, width, height, format, type, pixels, + /*honorPackImageParams=*/false, /*applyFixedPointReadClamp=*/true, + forceOpaqueAlpha)) { MGLOG_D("ReadPixels: finished via client-format conversion after native failure"); return; } @@ -6302,6 +6479,10 @@ namespace MobileGL::MG_Backend::DirectGLES { // for normalized attachments), while the conversion path reads a wide format that is always // accepted and repacks on the CPU. if (convertible) { + // The image being read is this texture, not whatever the application left bound to + // GL_READ_FRAMEBUFFER, so the widening question has to be asked of the texture. + const Bool forceOpaqueAlpha = + TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(), textureObject->GetTarget()); // GL_PACK_IMAGE_HEIGHT/GL_PACK_SKIP_IMAGES only apply to 3D/array image // readbacks (cube-map arrays address as arrays); 2D targets must ignore // them (GL 3.3 section 6.1.4). @@ -6347,7 +6528,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void* sliceDst = static_cast(pixels) + sliceOffset; if (!ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, sliceDst, /*honorPackImageParams=*/false, - /*applyFixedPointReadClamp=*/false)) { + /*applyFixedPointReadClamp=*/false, forceOpaqueAlpha)) { allSlicesRead = false; break; } @@ -6369,7 +6550,8 @@ namespace MobileGL::MG_Backend::DirectGLES { } if (tempFBOComplete && ReadPixelsViaFormatConversion(0, 0, size.x(), size.y(), format, type, pixels, applyPackImageParams, - /*applyFixedPointReadClamp=*/false)) { + /*applyFixedPointReadClamp=*/false, + forceOpaqueAlpha)) { MGLOG_D("GetTexImage: finished via client-format conversion"); return; } diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index d594a52a..9207f9a7 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -188,6 +188,26 @@ namespace MobileGL::MG_Backend::DirectGLES { void OnBackendContextDestroyed(); } // namespace XfbImpl + namespace RenderStateImpl { + // Pushes the frontend's render-state block to the ES driver, diffed against what was + // last pushed. + // + // `forColorClear` names the CALLER, and the only thing it changes is the colour write + // mask handed to the driver. A draw into a colour attachment the backend widened from + // three channels to four gets that buffer's alpha channel masked OFF, so nothing can + // move the stored alpha away from the 1.0 the application's three-channel format + // implies (see FramebufferImpl::g_alphaWidenedDrawBufferMask). A CLEAR is how that 1.0 + // gets there in the first place, so it must be allowed to write alpha - hence the flag + // rather than an unconditional doctoring. It is part of the sync memo, so a clear + // followed by a draw re-pushes the mask instead of early-outing on an unchanged + // frontend version. + // + // The application's own colour mask is never modified: glGet(GL_COLOR_WRITEMASK) + // answers from the frontend state, which this function only reads. + void SyncRenderState(Bool forColorClear = false); + void InvalidateSyncedRenderState(); + } // namespace RenderStateImpl + extern MG_External::EGLFunctionsTable g_EGLFuncs; extern MG_External::GLESFunctionsTable g_GLESFuncs; extern MG_External::GLESCapabilities g_GLESCapabilities; diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 00f4a73f..4a915fcb 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -1869,6 +1869,154 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + // Components per texel the frontend format's client data carries. Only the three-channel + // formats that can be widened to a four-channel render target need an answer (see + // PrepareChannelWidenedUpload); everything else keeps its own layout and reports 0. + Uint GetWidenableClientComponentCount(TextureInternalFormat format) { + switch (format) { + case TextureInternalFormat::RGB8Snorm: + case TextureInternalFormat::RGB16Snorm: + case TextureInternalFormat::RGB16: + case TextureInternalFormat::RGB10: // stored as RGB16 (UNorm16 shadow) + case TextureInternalFormat::RGB12: // stored as RGB16 (UNorm16 shadow) + case TextureInternalFormat::RGB16F: + case TextureInternalFormat::RGB32F: + case TextureInternalFormat::SRGB8: + case TextureInternalFormat::RGB8I: + case TextureInternalFormat::RGB8UI: + case TextureInternalFormat::RGB16I: + case TextureInternalFormat::RGB16UI: + case TextureInternalFormat::RGB32I: + case TextureInternalFormat::RGB32UI: + return 3; + default: + return 0; + } + } + + // True when the widened format's client data is integer rather than normalized. The two + // classes share every narrow component type - GL_RGB8I and GL_RGB8_SNORM are both uploaded + // as GL_BYTE - but their "1.0" differs: an integer channel's one is the integer 1, a + // normalized channel's is the saturated field. The type alone cannot tell them apart, so + // the source format has to. + Bool IsIntegerWidenableFormat(TextureInternalFormat format) { + switch (format) { + case TextureInternalFormat::RGB8I: + case TextureInternalFormat::RGB8UI: + case TextureInternalFormat::RGB16I: + case TextureInternalFormat::RGB16UI: + case TextureInternalFormat::RGB32I: + case TextureInternalFormat::RGB32UI: + return true; + default: + return false; + } + } + + // The bit pattern of 1.0 in an upload component type: what a format without alpha reads + // back as, and therefore what the synthetic fourth channel of a widened render target has + // to hold. Integer components carry the integer one, not a saturated field - and since + // GL_BYTE/GL_SHORT/GL_UNSIGNED_BYTE/GL_UNSIGNED_SHORT serve both classes, `integerData` + // is what decides, not the type. + static Bool GetUploadComponentOneBits(GLenum uploadType, Bool integerData, Uint8* outOneBits, + SizeT* outComponentSize) { + switch (uploadType) { + case GL_BYTE: { + const Int8 one = integerData ? Int8(1) : Int8(0x7F); + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_UNSIGNED_BYTE: { + const Uint8 one = integerData ? Uint8(1) : Uint8(0xFF); + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_SHORT: { + const Int16 one = integerData ? Int16(1) : Int16(0x7FFF); + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_UNSIGNED_SHORT: { + const Uint16 one = integerData ? Uint16(1) : Uint16(0xFFFF); + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_HALF_FLOAT: { + const Uint16 one = 0x3C00; // half 1.0 + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_FLOAT: { + const Float one = 1.0f; + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_INT: { + const Int32 one = 1; + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + case GL_UNSIGNED_INT: { + const Uint32 one = 1; + Memcpy(outOneBits, &one, sizeof(one)); + *outComponentSize = sizeof(one); + return true; + } + default: + return false; + } + } + + // A three-channel format widened to four to keep a colour attachment renderable (see + // NormalizePixelFormat) is described to the driver as a four-component transfer, so the + // three-component client data has to be repacked with an alpha of 1.0 - otherwise the + // driver walks three texels' worth of data per four-texel row and the image shears. + // `componentCount` is the SOURCE component count and `byteSize` the source's size, so this + // runs after any type conversion (which keeps the component count) has already happened. + const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, + const void* data, SizeT byteSize, GLenum uploadType, + Vector& widenedData, Bool integerData) { + Uint8 oneBits[8] = {}; + SizeT componentSize = 0; + if (componentCount != 3 || data == nullptr || byteSize == 0 || + !GetUploadComponentOneBits(uploadType, integerData, oneBits, &componentSize)) { + return data; + } + + const SizeT srcTexelBytes = componentSize * componentCount; + // Sized from the level, never from the source: the driver reads a full + // width*height*depth*4 components for the transfer it was handed, so a source that + // somehow holds fewer texels must still leave a full destination behind (its tail + // reads as transparent black with the format's implied opaque alpha) rather than a + // short buffer the driver would run off the end of. + 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 / srcTexelBytes); + + widenedData.assign(texelCount * componentSize * 4, 0); + const auto* src = static_cast(data); + Uint8* dst = widenedData.data(); + for (SizeT i = 0; i < texelCount; ++i, dst += componentSize * 4) { + if (i < copyTexelCount) { + Memcpy(dst, src, srcTexelBytes); + src += srcTexelBytes; + } + Memcpy(dst + srcTexelBytes, oneBits, componentSize); + } + return widenedData.data(); + } + static const void* PrepareNormFloatFallbackUpload(TextureInternalFormat format, const IntVec3& texelSize, const void* data, @@ -1914,6 +2062,39 @@ namespace MobileGL::MG_Backend::DirectGLES { return convertedData.data(); } + // The two shadow -> upload conversions a fallback storage format can need, in order: + // the component type first (SNORM/UNORM shadows into the float the fallback stores), then + // the component count (three-channel client data into a four-channel widened render + // target). They compose: GL_RGB8_SNORM on a driver with no renderable three-channel + // format becomes GL_RGBA16F, so its Int8x3 shadow is converted to Float x3 and then + // repacked as Float x4 with alpha 1.0. + // + // Both scratch buffers belong to the caller so they outlive the returned pointer; the + // return value is `data` itself whenever neither conversion applies, which is what the + // sub-rect upload fast path tests for. + static const void* PrepareFallbackUpload(TextureInternalFormat format, TextureTarget target, + const IntVec3& texelSize, const void* data, SizeT byteSize, + GLenum uploadType, Vector& convertedData, + Vector& widenedData) { + const void* uploadData = + PrepareNormFloatFallbackUpload(format, texelSize, data, byteSize, uploadType, convertedData); + // The component-count switch first: it rules out every format that cannot be widened + // (which is nearly all of them, including GL_RGBA8) without touching the capability + // cache, so an ordinary atlas upload does not pay for a per-level cache lookup. + const Uint componentCount = GetWidenableClientComponentCount(format); + if (componentCount == 0 || !TextureImpl::BackendTextureFormatAddsAlpha(format, target)) { + return uploadData; + } + // The type conversion above rewrites the level into `convertedData` at four bytes per + // component while keeping the component count, so the widening's source size is that + // buffer's, not the shadow's. + const SizeT uploadByteSize = (!convertedData.empty() && uploadData == convertedData.data()) + ? convertedData.size() * sizeof(Float) + : byteSize; + return PrepareChannelWidenedUpload(componentCount, texelSize, uploadData, uploadByteSize, uploadType, + widenedData, IsIntegerWidenableFormat(format)); + } + // RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE // leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is // implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors, @@ -2121,9 +2302,10 @@ namespace MobileGL::MG_Backend::DirectGLES { ? textureMipmapObject->MapMipmapData(uploadTarget, level) : nullptr; Vector convertedUploadData; - const void* uploadData = PrepareNormFloatFallbackUpload( - textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, - convertedUploadData); + Vector widenedUploadData; + const void* uploadData = PrepareFallbackUpload( + textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, uploadData, levelByteSize, &glType, packedUploadData); @@ -2253,9 +2435,10 @@ namespace MobileGL::MG_Backend::DirectGLES { auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level); Vector convertedUploadData; - const void* uploadData = PrepareNormFloatFallbackUpload( - textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, - convertedUploadData); + Vector widenedUploadData; + const void* uploadData = PrepareFallbackUpload( + textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, @@ -2312,9 +2495,10 @@ namespace MobileGL::MG_Backend::DirectGLES { ? textureMipmapObject->MapMipmapData(uploadTarget, level) : nullptr; Vector convertedUploadData; - const void* uploadData = PrepareNormFloatFallbackUpload( - textureMipmapObject->GetFormat(), levelTexelSize, pData, levelByteSize, glType, - convertedUploadData); + Vector widenedUploadData; + const void* uploadData = PrepareFallbackUpload( + textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, @@ -2422,9 +2606,10 @@ namespace MobileGL::MG_Backend::DirectGLES { auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level); Vector convertedUploadData; - const void* uploadData = PrepareNormFloatFallbackUpload( - textureMipmapObject->GetFormat(), texelSize, mipData, byteSize, glType, - convertedUploadData); + Vector widenedUploadData; + const void* uploadData = PrepareFallbackUpload( + textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize, + glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize, uploadData, byteSize, &glType, packedUploadData); @@ -2827,7 +3012,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s(%s:%d) ES error %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); - // A three-channel format widened to four for a multisample target (see + // A three-channel format widened to four to keep the image colour-renderable (see // NormalizePixelFormat) gains an alpha channel the frontend format does not have, and // whatever the draw that filled it wrote there is not what GL would report: a format // without alpha reads back as 1.0. Answer the ALPHA swizzle source with ONE so the @@ -3139,6 +3324,113 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } + // The colour attachment glReadPixels/glGetTexImage would read from, or nullptr when the + // read buffer names no colour attachment at all. + static const MG_State::GLState::FramebufferAttachmentObject* GetReadColorAttachment() { + const auto& readFBO = + MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + if (!readFBO) { + return nullptr; + } + const auto readBuffer = readFBO->GetReadBuffer(); + if (readBuffer < FramebufferAttachmentType::Color0 || readBuffer > FramebufferAttachmentType::Color31) { + return nullptr; + } + return &readFBO->GetAttachment(readBuffer); + } + + Bool IsAlphaWidenedColorAttachment( + const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) { + if (attachmentObject.IsTexture()) { + const auto& textureObject = attachmentObject.GetTexture(); + return textureObject && TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(), + textureObject->GetTarget()); + } + if (attachmentObject.IsRenderbuffer()) { + const auto& renderbufferObject = attachmentObject.GetRenderbuffer(); + return renderbufferObject && + TextureImpl::BackendRenderbufferFormatAddsAlpha(renderbufferObject->GetInternalFormat()); + } + return false; + } + + Uint32 g_alphaWidenedDrawBufferMask = 0; + Uint32 g_integerColorDrawBufferMask = 0; + + static Bool IsIntegerColorFormat(TextureInternalFormat format) { + switch (format) { + case TextureInternalFormat::R8I: + case TextureInternalFormat::R8UI: + case TextureInternalFormat::R16I: + case TextureInternalFormat::R16UI: + case TextureInternalFormat::R32I: + case TextureInternalFormat::R32UI: + case TextureInternalFormat::RG8I: + case TextureInternalFormat::RG8UI: + case TextureInternalFormat::RG16I: + case TextureInternalFormat::RG16UI: + case TextureInternalFormat::RG32I: + case TextureInternalFormat::RG32UI: + case TextureInternalFormat::RGB8I: + case TextureInternalFormat::RGB8UI: + case TextureInternalFormat::RGB16I: + case TextureInternalFormat::RGB16UI: + case TextureInternalFormat::RGB32I: + case TextureInternalFormat::RGB32UI: + case TextureInternalFormat::RGBA8I: + case TextureInternalFormat::RGBA8UI: + case TextureInternalFormat::RGBA16I: + case TextureInternalFormat::RGBA16UI: + case TextureInternalFormat::RGBA32I: + case TextureInternalFormat::RGBA32UI: + case TextureInternalFormat::RGB10A2UI: + return true; + default: + return false; + } + } + + static Bool IsIntegerColorAttachment( + const MG_State::GLState::FramebufferAttachmentObject& attachmentObject) { + if (attachmentObject.IsTexture()) { + const auto& textureObject = attachmentObject.GetTexture(); + return textureObject && IsIntegerColorFormat(textureObject->GetFormat()); + } + if (attachmentObject.IsRenderbuffer()) { + const auto& renderbufferObject = attachmentObject.GetRenderbuffer(); + return renderbufferObject && IsIntegerColorFormat(renderbufferObject->GetInternalFormat()); + } + return false; + } + + Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo) { + using FBO = MG_State::GLState::FramebufferObject; + const auto& drawBuffers = fbo.GetDrawBuffers(); + Uint32 mask = 0; + for (Uint i = 0; i < FBO::MAX_DRAW_BUFFERS && i < 32; ++i) { + const auto frontendBuf = drawBuffers[i]; + if (frontendBuf < FramebufferAttachmentType::Color0 || + frontendBuf > FramebufferAttachmentType::Color31) { + continue; + } + if (IsAlphaWidenedColorAttachment(fbo.GetAttachment(frontendBuf))) { + mask |= (1u << i); + } + } + return mask; + } + + // The read attachment's storage carries an alpha channel its frontend format does not + // (the three-channel colour-renderable widening). GL answers such a read with 1.0, but + // the storage holds whatever the draw wrote there, so the readback has to overwrite it. + Bool IsAlphaWidenedFallbackReadAttachment() { + const auto* attachmentObject = GetReadColorAttachment(); + if (attachmentObject == nullptr) { + return false; + } + return IsAlphaWidenedColorAttachment(*attachmentObject); + } + Bool IsFixedPointFallbackReadAttachment() { const auto& readFBO = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); @@ -3344,6 +3636,8 @@ namespace MobileGL::MG_Backend::DirectGLES { if (asTarget == FramebufferTarget::Draw) { Uint32 snormClampOutputMask = 0; Uint32 unormClampOutputMask = 0; + Uint32 alphaWidenedMask = 0; + Uint32 integerColorMask = 0; for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) { const auto frontendBuf = stateDrawBuffers[i]; if (frontendBuf < FramebufferAttachmentType::Color0 || @@ -3356,9 +3650,21 @@ namespace MobileGL::MG_Backend::DirectGLES { } else if (IsUnormFallbackAttachment(attachmentObject)) { unormClampOutputMask |= (1u << i); } + // Independent of the two above: a widened attachment can be SNORM + // (GL_RGB8_SNORM -> GL_RGBA16F, which also clamps) or not (GL_SRGB8 -> + // GL_SRGB8_ALPHA8, which does not), so it gets its own bit rather than an + // `else if` branch of theirs. + if (IsAlphaWidenedColorAttachment(attachmentObject)) { + alphaWidenedMask |= (1u << i); + } + if (IsIntegerColorAttachment(attachmentObject)) { + integerColorMask |= (1u << i); + } } PrgramImpl::g_snormFallbackClampOutputMask = snormClampOutputMask; PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask; + g_alphaWidenedDrawBufferMask = alphaWidenedMask; + g_integerColorDrawBufferMask = integerColorMask; } // 2. Remap read buffer. glReadBuffer writes the READ-bound FBO's state, so diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index d3458e0d..721c7687 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -515,6 +515,25 @@ namespace MobileGL::MG_Backend::DirectGLES { return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap; } + // Components per texel the frontend format's client data carries, for the three-channel + // formats that can be widened to a four-channel colour-renderable target; 0 for everything + // else. See PrepareChannelWidenedUpload. + Uint GetWidenableClientComponentCount(TextureInternalFormat format); + + // True when a widenable format's components are integer rather than normalized, which is + // what decides the synthetic alpha's value: GL_RGB8I and GL_RGB8_SNORM are both uploaded + // as GL_BYTE, but their 1.0 is 1 and 0x7F respectively. + Bool IsIntegerWidenableFormat(TextureInternalFormat format); + + // Repacks three-component client data as four components with an alpha of 1.0 in + // `uploadType`, for a format the backend widened to keep a colour attachment renderable. + // Returns `data` untouched when no widening applies. Pure CPU and context-free so a unit + // test can exercise the exact packing the driver is handed; `widenedData` is the caller's + // scratch buffer and has to outlive the returned pointer. + const void* PrepareChannelWidenedUpload(Uint componentCount, const IntVec3& texelSize, const void* data, + SizeT byteSize, GLenum uploadType, Vector& widenedData, + Bool integerData = false); + struct StateTextureBasicInfo { // Used for tracking texture state changes TextureInternalFormat internalFormat = TextureInternalFormat::Unknown; SizeT width = 0; @@ -714,6 +733,67 @@ namespace MobileGL::MG_Backend::DirectGLES { // has to apply the clamp itself. Bool IsFixedPointFallbackReadAttachment(); + // True when the read buffer names a three-channel attachment the backend actually stores + // in a four-channel format (the colour-renderable widening). A format without alpha reads + // back as 1.0, so the readback path has to overwrite the alpha the draw left behind - + // unconditionally, since this is the format's own semantics rather than the + // GL_CLAMP_READ_COLOR rule the clamp above implements. + Bool IsAlphaWidenedFallbackReadAttachment(); + + // True when this attachment's storage carries an alpha channel its frontend format does + // not (the three-channel colour-renderable widening). + Bool IsAlphaWidenedColorAttachment(const MG_State::GLState::FramebufferAttachmentObject& attachmentObject); + + // Bit i set = DRAW BUFFER i of `fbo` resolves to a colour attachment the backend widened + // from three channels to four. Indexed by draw-buffer slot, not by attachment point, + // because that is what glColorMaski / glClearBufferfv address. + Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo); + + // The same mask for whatever is currently bound to GL_DRAW_FRAMEBUFFER, recomputed by + // SyncCurrentFBO (BackendFramebufferObject::SyncToBackend for the DRAW target, and reset + // to 0 on the default framebuffer). Read by the draw/clear state sync, so it is only + // trustworthy after SyncCurrentFBO has run in the same entry point. + // + // WHY IT EXISTS (the dst-alpha discipline). A widened attachment has a real alpha channel + // the application's format does not, and GL says a missing channel reads as 1.0. Readback + // can paper over that (ForceWideReadAlphaToOne), but GL_DST_ALPHA / + // GL_ONE_MINUS_DST_ALPHA blending and glBlitFramebuffer read the STORED alpha inside the + // driver where no interception is possible. So the stored alpha is kept at 1.0 instead: + // a clear touching a widened buffer writes alpha 1.0, and every draw into it has its + // alpha write mask forced off, so nothing can ever move it again. The application's own + // colour mask is untouched - glGet(GL_COLOR_WRITEMASK) still reports what it set. + extern Uint32 g_alphaWidenedDrawBufferMask; + + // Bit i set = DRAW BUFFER i of the framebuffer bound as DRAW resolves to a colour + // attachment with an INTEGER format. Recomputed beside the mask above and for its sake: + // glClearBufferfv on an integer colour buffer is GL_INVALID_OPERATION, so the + // per-draw-buffer clear route the widening needs has to stand down when one is present. + // (glClear on an integer colour buffer is left undefined by ES in the first place, and + // an application that wants a defined answer has to call glClearBufferuiv/iv - which does + // carry the widened alpha substitution.) + extern Uint32 g_integerColorDrawBufferMask; + + // The colour a clear has to hand the driver for one draw buffer: the application's value, + // except that a widened attachment's alpha is replaced by the 1.0 its three-channel + // format implies. `one` is 1.0 encoded in the clear call's own component type - the + // integer clears carry the integer 1, the float clear carries 1.0f. + // + // Returns `value` itself when nothing is substituted, so the ordinary path allocates and + // copies nothing; `scratch` is the caller's buffer and has to outlive the returned + // pointer. Free of GL state on purpose, so the substitution can be unit-tested exactly as + // the driver sees it. + template + const T* SubstituteWidenedClearAlpha(const T* value, Bool widened, T one, T (&scratch)[4]) { + if (!widened || value == nullptr) { + return value; + } + scratch[0] = value[0]; + scratch[1] = value[1]; + scratch[2] = value[2]; + scratch[3] = one; + return scratch; + } + // What SyncCurrentFBO last pushed for each target, as a (binding, object, revision) // triple; it re-syncs unless all three still match. Stamped by SyncCurrentFBO and // ForceBindCurrentFBO, cleared by InvalidateFramebufferBindingCache. The three are diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 98c95200..42d10487 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -61,29 +61,6 @@ namespace MobileGL::MG_Backend::DirectGLES { requestedInternalFormat, GetDriverPixelFormatNormalizeOptions() | extraOptions); } - // Multisample textures can only ever be rendered into, never uploaded to, so a fallback - // format for them has to stay colour-renderable - a three-channel float fallback is a legal - // ES texture format but not a legal multisample storage format. Widening to four channels - // is safe here precisely because there is no transfer path that would have to expand - // three-channel client data, and the alpha the draw writes for a three-channel source is - // already the 1.0 the frontend format implies. - Bool TargetRequiresRenderableFormat(SizeT targetIndex) { - return targetIndex == static_cast(TextureTarget::Texture2DMultisample) || - targetIndex == static_cast(TextureTarget::Texture2DMultisampleArray); - } - - Flags GetRenderTargetNormalizeOptions(SizeT targetIndex) { - Flags options; - if (!TargetRequiresRenderableFormat(targetIndex)) { - return options; - } - options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; - if (!g_GLESCapabilities.SupportsRenderSnorm || !g_GLESCapabilities.SupportsNorm16Texture) { - options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; - } - return options; - } - Bool HasCachedFormatCapability(TextureInternalFormat internalFormat, SizeT targetIndex, Bool caveat, @@ -141,14 +118,61 @@ namespace MobileGL::MG_Backend::DirectGLES { const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); Flags options; if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) { - options = GetRuntimeFallbackNormalizeOptions(requestedInternalFormat, - GetRenderTargetNormalizeOptions(targetIndex)); + options = GetRuntimeFallbackNormalizeOptions( + requestedInternalFormat, + TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex)); } NormalizePixelFormat(requestedInternalFormat, options, outInternalFormat, outFormat, outType); } } // namespace namespace TextureImpl { + // Every image that can back a colour attachment needs a colour-renderable storage format, + // and ES has no renderable three-channel format at all: a three-channel float fallback is + // a legal ES texture but neither legal multisample storage nor a legal attachment, so + // GL_RGB8_SNORM / GL_RGB16F / ... have to be widened to four channels for any of them. + // This used to cover the multisample pair alone, on the grounds that only those can never + // be uploaded to; the transfer paths now expand three-channel client data themselves + // (Managers.cpp PrepareFallbackUpload) and hide the added alpha again on sample and + // readback, so the same substitution is available everywhere. + // + // The widening only ever *happens* where the driver refuses the native form (see + // PopulateFormatCapabilitiesImpl: outside multisample storage it rides the driver branch, + // behind the native probe), so a driver that does render to a three-channel image keeps + // allocating it byte for byte. + // + // Do NOT read that as "nothing changes off-device". Measured on Mesa 26.1.6 llvmpipe + // (the headless CI driver), an ES 3.2 GL_TEXTURE_2D colour attachment is COMPLETE for + // GL_RGB8 and GL_RGB16F but INCOMPLETE_ATTACHMENT for GL_RGB8_SNORM, GL_SRGB8 and every + // RGB integer format, and UNSUPPORTED for GL_RGB32F. Those eight formats therefore DO + // take the widened path on llvmpipe, which is where the retrace fixtures and the glcts + // green suites run - the substitution is driver-conditional, not desktop-exempt. + // + // A buffer texture is the one image that can never be an attachment; its storage is the + // buffer object's, and widening it would misdescribe the application's data. + Bool TargetRequiresRenderableFormat(SizeT targetIndex) { + if (targetIndex >= kFormatCapabilityTargetCount) { + return false; + } + if (targetIndex == kFormatCapabilityRenderbufferTargetIndex) { + return true; + } + return static_cast(targetIndex) != TextureTarget::TextureBuffer; + } + + Flags GetRenderTargetNormalizeOptions( + const MG_External::GLESCapabilities& capabilities, SizeT targetIndex) { + Flags options; + if (!TargetRequiresRenderableFormat(targetIndex)) { + return options; + } + options |= PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; + if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) { + options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; + } + return options; + } + void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType, TextureTarget target) { #ifdef TRACY_ENABLE @@ -178,20 +202,29 @@ namespace MobileGL::MG_Backend::DirectGLES { return ShouldUseCaveatFormat(internalFormat, GetRenderbufferFormatCapabilityTargetIndex()); } + namespace { + Bool BackendFormatAddsAlpha(TextureInternalFormat internalFormat, SizeT targetIndex) { + if (!TargetRequiresRenderableFormat(targetIndex)) { + return false; + } + if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) { + return false; + } + const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); + const Flags options = GetRuntimeFallbackNormalizeOptions( + requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex)); + return static_cast(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget); + } + } // namespace + Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target) { const SizeT targetIndex = target == TextureTarget::Unknown ? kFormatCapabilityTargetCount : GetFormatCapabilityTargetIndex(target); - if (!TargetRequiresRenderableFormat(targetIndex)) { - return false; - } - if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) { - return false; - } - const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); - const Flags options = - GetRuntimeFallbackNormalizeOptions(requestedInternalFormat, - GetRenderTargetNormalizeOptions(targetIndex)); - return static_cast(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget); + return BackendFormatAddsAlpha(internalFormat, targetIndex); + } + + Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) { + return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex()); } } // namespace TextureImpl namespace PrgramImpl { diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index e433df2a..841636ba 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -9,6 +9,8 @@ #pragma once #include #include +#include +#include namespace MobileGL::MG_Backend::DirectGLES { namespace DebugImpl { @@ -34,6 +36,16 @@ namespace MobileGL::MG_Backend::DirectGLES { } // namespace VertexArrayImpl namespace TextureImpl { + // Whether images on this format-capability target can back a colour attachment, and so + // need a colour-renderable storage format even when the frontend asked for a + // three-channel one ES never renders to. Shared by the capability probe (which passes the + // capabilities it has just queried, before the globals are published) and by the + // allocation path (which reads the active backend's), so the format the cache was probed + // with is always the format the image is created with. + Bool TargetRequiresRenderableFormat(SizeT targetIndex); + Flags GetRenderTargetNormalizeOptions( + const MG_External::GLESCapabilities& capabilities, SizeT targetIndex); + void GenerateTextureFormatInfo(TextureInternalFormat internalFormat, GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType, TextureTarget target = TextureTarget::Unknown); @@ -41,10 +53,12 @@ namespace MobileGL::MG_Backend::DirectGLES { GLenum* outFormat, GLenum* outType); Bool ShouldUseCaveatTextureFormat(TextureInternalFormat internalFormat, TextureTarget target); - // True when the format the texture is actually created with has an alpha channel the - // frontend format does not (the three-channel multisample widening). GL reads such a - // channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE. + // True when the format the image is actually created with has an alpha channel the + // frontend format does not (the three-channel colour-renderable widening). GL reads such + // a channel back as 1.0, so any swizzle source of ALPHA has to be answered with ONE and + // any readback of the image has to overwrite the alpha the draw happened to leave there. Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target); + Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat); Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat); } // namespace TextureImpl diff --git a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp index cdf82fd6..fc4cee01 100644 --- a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp +++ b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp @@ -106,9 +106,11 @@ namespace MobileGL::MG_Impl::GLImpl { // `capabilityTargetIndex` is the row of the cache the attachment actually lives in; // kFormatCapabilityTargetCount asks about the format in general. Asking per target matters // because a capability recorded for one of them says nothing about the others: DirectGLES - // widens three-channel formats to four channels to keep them renderable as *multisample* - // storage, and a format that survives only through that substitution is still texture-only - // on every ordinary target. + // decides each target's substitution against that target's own probe, and a buffer texture + // never gets one at all. This is also where the three-channel widening becomes visible to + // the application - a GL_RGB8_SNORM colour attachment on a driver with no renderable + // three-channel format answers COMPLETE because the backend stores it as GL_RGBA16F and + // recorded FramebufferRenderable in CaveatCaps. Bool IsColorInternalFormatRenderable(TextureInternalFormat format, SizeT capabilityTargetIndex) { const SizeT formatIndex = static_cast(format); if (MG_Backend::pActiveBackendObject && formatIndex < MG_Backend::kFormatCapabilityFormatCount) { diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 25afbda7..745adcfe 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -52,6 +52,7 @@ add_executable(MobileGLIntegrationTest Scenarios/MultiDrawScenario.cpp Scenarios/AsyncCompileScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp + Scenarios/ThreeChannelAttachmentScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.cpp new file mode 100644 index 00000000..08e8d6b8 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.cpp @@ -0,0 +1,300 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ThreeChannelAttachmentScenario.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 - THREE-CHANNEL COLOUR ATTACHMENTS, on a live driver. +// +// The bug: no OpenGL ES driver renders to a three-channel image. EXT_render_snorm covers +// R/RG/RGBA only, EXT_color_buffer_float excludes RGB16F, and RGB integer formats are not +// colour-renderable anywhere. Complementary Reimagined declares colortex1 = RGB8_SNORM and +// colortex2 = RGB16F, so every framebuffer Iris built from them answered +// GL_FRAMEBUFFER_UNSUPPORTED and Iris refused to load the shaderpack. DirectGLES now stores such +// an attachment in its four-channel sibling (GL_RGB8_SNORM -> GL_RGBA16F) and reports the +// substitution as a caveat capability, which is what makes glCheckFramebufferStatus say COMPLETE. +// +// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in +// MG_Test/Framebuffer/FramebufferTest.cpp drive a HAND-BUILT capability cache: they prove the +// frontend accepts a caveat capability, and prove the colour-mask/clear discipline that keeps a +// widened attachment's stored alpha at 1.0, but they cannot prove that a real driver's probe +// actually PRODUCES that caveat. Only a live glCheckFramebufferStatus can, and the answer is +// per-driver, not per-platform: +// +// Mesa llvmpipe (the headless CI driver), ES 3.2, GL_TEXTURE_2D colour attachment: +// COMPLETE GL_RGB8, GL_RGB16F, GL_R11F_G11F_B10F, every RGBA* +// INCOMPLETE_ATTACHMENT GL_RGB8_SNORM, GL_SRGB8, every RGB integer format +// UNSUPPORTED GL_RGB32F +// +// So the widening is LIVE on llvmpipe - "the desktop build is unaffected" was simply wrong, and +// the CI retraces were green before the fix only because retrace ignores what +// glCheckFramebufferStatus returns. This scenario is the gate that actually looks. +// +// DirectGLES only. DirectVulkan's format story is its own (Vulkan exposes R8G8B8_SNORM on almost +// nothing, and Magma substitutes on different terms); asserting Espryt's answers there would +// only pin a coincidence. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + // Two outputs so the mixed case is covered: draw buffer 0 is a natively renderable + // four-channel format whose alpha the application owns, draw buffer 1 is the widened + // three-channel one whose alpha the format says is 1.0. Both alphas are deliberately + // NOT 1.0 in the shader, so an implementation that simply passed the value through would + // fail the second assertion. + constexpr const char* kFS = R"(#version 330 core +layout(location = 0) out vec4 oNative; +layout(location = 1) out vec4 oWidened; +void main() { + oNative = vec4(1.0, 0.0, 0.0, 0.25); + oWidened = vec4(0.0, 1.0, 0.0, 0.75); +} +)"; + + constexpr int kSize = 16; + + class ThreeChannelAttachmentScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + if (Gl().BackendName() != "DirectGLES") { + GTEST_SKIP() << "three-channel widening is a DirectGLES substitution; backend is " + << Gl().BackendName(); + } + } + + // A single-level 2D texture in `internalFormat`, or 0 when the driver rejects the + // storage outright (which is a different failure from rejecting the ATTACHMENT). + static GLuint MakeTexture(GLenum internalFormat) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kSize, kSize); + if (glGetError() != GL_NO_ERROR) { + glDeleteTextures(1, &texture); + return 0; + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glBindTexture(GL_TEXTURE_2D, 0); + return texture; + } + + static GLenum SingleAttachmentStatus(GLenum internalFormat) { + const GLuint texture = MakeTexture(internalFormat); + if (texture == 0) return GL_NONE; + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + const GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + return status; + } + }; + + // THE regression gate for the frontend's answer: this is the exact call Iris makes, and + // GL_FRAMEBUFFER_UNSUPPORTED here is the whole shaderpack load failure. + TEST_F(ThreeChannelAttachmentScenario, ThreeChannelColorAttachmentsReportComplete) { + if (!Ready() || IsSkipped()) return; + + // GL_RGB8 is the control: colour-renderable in ES core, so it must pass with or + // without any substitution. If it ever fails, nothing below means anything. + EXPECT_EQ(SingleAttachmentStatus(GL_RGB8), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "GL_RGB8 is ES-core colour-renderable"; + + // Complementary Reimagined's colortex1 and colortex2. + EXPECT_EQ(SingleAttachmentStatus(GL_RGB8_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "colortex1 (RGB8_SNORM) must be renderable through the four-channel widening"; + EXPECT_EQ(SingleAttachmentStatus(GL_RGB16F), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "colortex2 (RGB16F) must be renderable, natively or through the widening"; + + // The other formats the widening covers. GL_RGB32F only reaches a renderable + // four-channel form when EXT_color_buffer_float is present, so a half-float-only + // driver legitimately answers UNSUPPORTED for it - see the POST's per-format row. + EXPECT_EQ(SingleAttachmentStatus(GL_SRGB8), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(SingleAttachmentStatus(GL_RGB8UI), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The other half: the substitution has to be INVISIBLE. A three-channel format has no + // alpha, so GL answers 1.0 for it - and that answer has to hold after a draw that wrote + // something else into the widened storage's real alpha channel, which is what the + // colour-mask discipline in SyncRenderState is for. GL_DST_ALPHA blending and + // glBlitFramebuffer read that stored alpha inside the driver, where no readback fixup can + // reach it, so "the storage really holds 1.0" is the only workable invariant. + TEST_F(ThreeChannelAttachmentScenario, WidenedAttachmentReadsBackOpaqueWhileItsNeighbourKeepsItsAlpha) { + if (!Ready() || IsSkipped()) return; + + std::string error; + const GLuint program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + const GLuint nativeTexture = MakeTexture(GL_RGBA16F); + const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM); + ASSERT_NE(nativeTexture, 0u); + ASSERT_NE(widenedTexture, 0u); + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, nativeTexture, 0); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, widenedTexture, 0); + const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1}; + glDrawBuffers(2, drawBuffers); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + glViewport(0, 0, kSize, kSize); + // Alpha 0.0 on purpose: the widened attachment must come back 1.0 anyway, and the + // native one must come back 0.0 where the draw does not cover it. + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + + const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + std::vector pixels(static_cast(kSize) * kSize * 4, -1.0f); + + glReadBuffer(GL_COLOR_ATTACHMENT1); + glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data()); + EXPECT_NEAR(pixels[0], 0.0f, 0.02f) << "widened attachment red"; + EXPECT_NEAR(pixels[1], 1.0f, 0.02f) << "widened attachment green"; + EXPECT_NEAR(pixels[2], 0.0f, 0.02f) << "widened attachment blue"; + EXPECT_NEAR(pixels[3], 1.0f, 0.001f) + << "a three-channel format has no alpha channel, so GL must report 1.0 for it"; + + glReadBuffer(GL_COLOR_ATTACHMENT0); + glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data()); + EXPECT_NEAR(pixels[0], 1.0f, 0.02f) << "native attachment red"; + EXPECT_NEAR(pixels[3], 0.25f, 0.02f) + << "the alpha discipline must not leak onto a natively renderable attachment"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &nativeTexture); + glDeleteTextures(1, &widenedTexture); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The case above can be satisfied by the readback fixup alone (ForceWideReadAlphaToOne + // rewrites glReadPixels' alpha), so it does NOT prove the STORED alpha is 1.0. This one + // does, by asking the driver to read that alpha itself: GL_DST_ALPHA blending multiplies + // by the destination alpha inside the raster pipeline, where nothing MobileGL does can + // intervene. Same reason GL_ONE_MINUS_DST_ALPHA and glBlitFramebuffer are covered for + // free once this holds - and the reason the discipline is a write mask rather than a + // readback patch. + // + // Ablation-checked on llvmpipe, each half separately: disable the alpha doctoring in + // SyncRenderState and the opaque draw leaves 0.25 in the stored alpha; disable the clear + // substitution in Clear() and it stays at the application's 0.0. Either way this case + // reads back the wrong number, which is what makes it a gate rather than a description. + TEST_F(ThreeChannelAttachmentScenario, DstAlphaBlendingSeesOneInAWidenedAttachment) { + if (!Ready() || IsSkipped()) return; + + static constexpr const char* kSingleOutFS = R"(#version 330 core +out vec4 oColor; +uniform vec4 uColor; +void main() { oColor = uColor; } +)"; + std::string error; + const GLuint program = CompileProgram(kVS, kSingleOutFS, &error); + ASSERT_NE(program, 0u) << error; + const GLint colorLocation = glGetUniformLocation(program, "uColor"); + ASSERT_GE(colorLocation, 0); + + const GLuint widenedTexture = MakeTexture(GL_RGB8_SNORM); + ASSERT_NE(widenedTexture, 0u); + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, widenedTexture, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glViewport(0, 0, kSize, kSize); + + // The clear's alpha is 0.0 and the draw's is 0.25 - neither is the 1.0 the format + // implies, so both halves of the discipline have to fire for the blend below to see + // 1.0: the clear substitutes it, and the draw is masked away from it. + glDisable(GL_BLEND); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + // dst = stored alpha; src factor GL_DST_ALPHA, dst factor GL_ZERO, source white + // => the destination colour becomes (storedAlpha, storedAlpha, storedAlpha). + glEnable(GL_BLEND); + glBlendFunc(GL_DST_ALPHA, GL_ZERO); + glUniform4f(colorLocation, 1.0f, 1.0f, 1.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glDisable(GL_BLEND); + + std::vector pixels(static_cast(kSize) * kSize * 4, -1.0f); + glReadBuffer(GL_COLOR_ATTACHMENT0); + glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data()); + EXPECT_NEAR(pixels[0], 1.0f, 0.02f) + << "GL_DST_ALPHA read the stored alpha of a three-channel attachment; it must be 1.0"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &widenedTexture); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp index d2642d6b..1d68cb26 100644 --- a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp +++ b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp @@ -13,10 +13,13 @@ #include "Includes.h" #include "Init.h" #include +#include +#include #include #include #include #include +#include #include #include @@ -858,3 +861,394 @@ TEST_F(FramebufferTest, NonRenderableColorFormatsReportUnsupportedFramebuffer) { MG_Impl::GLImpl::ReadPixels(0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, pixelStorage); EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_INVALID_FRAMEBUFFER_OPERATION); } + +// ---- Three-channel colour attachments: the Complementary Reimagined / Iris load failure -------- +// +// Complementary declares colortex1 = RGB8_SNORM and colortex2 = RGB16F. No real OpenGL ES driver +// renders to a three-channel image (EXT_render_snorm covers R/RG/RGBA only; EXT_color_buffer_float +// excludes RGB16F), so the DirectGLES probe records those formats as creatable-but-not-renderable +// and the frontend answered every framebuffer built from them GL_FRAMEBUFFER_UNSUPPORTED - which +// Iris turns into a hard "Draw buffers [0, 1] Status: 36061" load failure. The backend now records +// the four-channel substitution it will actually allocate as a caveat capability, and the frontend +// has to accept that as renderable. +namespace { + class ThreeChannelAttachmentBackend final : public MG_Backend::BackendObject { + public: + // `substituted` stands in for a driver where the four-channel widening probe succeeded, i.e. + // for what PopulateFormatCapabilitiesImpl records on Mali. false is the pre-fix state: the + // native form is creatable, nothing is renderable, and no fallback was ever built. + explicit ThreeChannelAttachmentBackend(Bool substituted) { + auto& cache = MutableFormatCapabilities(); + const auto texture2DIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D); + + // IsColorInternalFormatRenderable only trusts the cache once it looks populated, which + // it decides from RGBA8 being creatable somewhere. Without this the static deny-list + // answers instead and the caveat below would never be consulted. + const auto rgba8Index = static_cast(TextureInternalFormat::RGBA8); + cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::Creatable; + cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::FramebufferRenderable; + cache.FullCaps[texture2DIndex][rgba8Index] |= MG_Backend::FormatCapability::ColorAttachment; + + for (const TextureInternalFormat format : + {TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F}) { + const auto formatIndex = static_cast(format); + // Creatable and samplable as an ordinary texture, but the driver's + // glCheckFramebufferStatus said no - exactly Mali r32p1's answer. + cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Creatable; + cache.FullCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::Sampled; + if (substituted) { + cache.CaveatCaps[texture2DIndex][formatIndex] |= + MG_Backend::FormatCapability::FramebufferRenderable; + cache.CaveatCaps[texture2DIndex][formatIndex] |= MG_Backend::FormatCapability::ColorAttachment; + } + } + } + + void Initialize() override {} + Bool InitCapabilities() override { return true; } + Bool InitWindowSurface() override { return true; } + const RendererInfo& GetRendererInfo() const override { + static RendererInfo info = {}; + return info; + } + String GetBackendAPIVersionString() const override { return {}; } + const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override { + static MG_Backend::GlobalBackendFunctionsTable table = {}; + return table; + } + const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override { + static MG_Backend::DynamicBackendParameters params = {}; + return params; + } + BackendType GetBackendType() const override { return BackendType::Unknown; } + }; + + class ScopedBackendOverride { + public: + explicit ScopedBackendOverride(UniquePtr backend): + m_previous(Move(MG_Backend::pActiveBackendObject)) { + MG_Backend::pActiveBackendObject = Move(backend); + } + + ~ScopedBackendOverride() { MG_Backend::pActiveBackendObject = Move(m_previous); } + + private: + UniquePtr m_previous; + }; + + GLenum CheckSingleColorAttachmentStatus(GLenum internalFormat) { + GLuint framebuffer = 0; + GLuint texture = 0; + MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &texture); + MG_Impl::GLImpl::TextureStorage2D(texture, 1, internalFormat, 4, 4); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, texture, 0); + return MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER); + } +} // namespace + +TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreUnsupportedWithoutTheWidenedSubstitution) { + // The pre-fix behaviour, pinned so a regression is a red test rather than a shaderpack that + // silently stops loading: no caveat capability, so nothing makes these renderable. + ScopedBackendOverride backend(MakeUnique(/*substituted=*/false)); + + EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast(GL_FRAMEBUFFER_UNSUPPORTED)); + EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast(GL_FRAMEBUFFER_UNSUPPORTED)); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +TEST_F(FramebufferTest, ThreeChannelColorAttachmentsAreCompleteThroughTheWidenedSubstitution) { + ScopedBackendOverride backend(MakeUnique(/*substituted=*/true)); + + // Complementary's colortex1 (RGB8_SNORM) and colortex2 (RGB16F): both must come out COMPLETE, + // because the backend stores them as GL_RGBA16F. Shipping only the first would move the + // failure one composite pass down instead of fixing it. + EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB8_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(CheckSingleColorAttachmentStatus(GL_RGB16F), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +TEST_F(FramebufferTest, TwoAttachmentCompositeFramebufferMatchesIrisComplementaryPass) { + // The exact framebuffer Iris failed on: Complementary's `composite` pass draws to colortex7 + // (RGBA16F, natively renderable) and colortex1 (RGB8_SNORM, only renderable widened). Iris + // logs it as "Draw buffers [0, 1]" - a two-attachment FBO, not colortex 0 and 1. + ScopedBackendOverride backend(MakeUnique(/*substituted=*/true)); + + GLuint framebuffer = 0; + GLuint colortex7 = 0; + GLuint colortex1 = 0; + MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1); + MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4); + MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0); + + EXPECT_EQ(MG_Impl::GLImpl::CheckNamedFramebufferStatus(framebuffer, GL_DRAW_FRAMEBUFFER), + static_cast(GL_FRAMEBUFFER_COMPLETE)); + + // Both entry points answer from the same helpers, and CheckFramebufferStatus is what Iris + // actually calls; they are near-verbatim duplicates, so assert they agree. + MG_Impl::GLImpl::BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer); + EXPECT_EQ(MG_Impl::GLImpl::CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER), + static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +// ---- Widened attachments: the stored-alpha discipline ----------------------------------------- +// +// A widened attachment has a real alpha channel the application's three-channel format does not, +// and GL says a channel a format lacks reads back as 1.0. glReadPixels and glGetTexImage can be +// made to say that (ForceWideReadAlphaToOne), but GL_DST_ALPHA / GL_ONE_MINUS_DST_ALPHA blending +// and glBlitFramebuffer read the STORED alpha inside the driver, where nothing can intercept it. +// So the stored alpha is held at 1.0 instead: a clear writes 1.0 into it, and every draw has that +// buffer's alpha write mask forced off so nothing can move it again. +// +// These cases pin the two halves of that pairing at the seam where they are visible - what the ES +// driver is actually handed - and pin the invariant that the application's own colour mask is +// never touched. +namespace { + struct RecordedColorMask { + Bool seen = false; + GLboolean r = GL_FALSE, g = GL_FALSE, b = GL_FALSE, a = GL_FALSE; + }; + + constexpr Uint kRecordedDrawBuffers = 8; + RecordedColorMask g_driverIndexedColorMasks[kRecordedDrawBuffers]; + RecordedColorMask g_driverUniformColorMask; + + void ResetRecordedColorMasks() { + for (auto& recorded : g_driverIndexedColorMasks) recorded = {}; + g_driverUniformColorMask = {}; + } + + void StubColorMask(GLboolean r, GLboolean g, GLboolean b, GLboolean a) { + g_driverUniformColorMask = {true, r, g, b, a}; + // The non-indexed call sets every draw buffer, so record it as such: a later assertion + // about draw buffer 1 must not read a stale indexed record the uniform push overwrote. + for (auto& recorded : g_driverIndexedColorMasks) recorded = {true, r, g, b, a}; + } + + void StubColorMaski(GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a) { + if (index < kRecordedDrawBuffers) g_driverIndexedColorMasks[index] = {true, r, g, b, a}; + } + + 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 StubBlendEquationSeparate(GLenum, GLenum) {} + void StubBlendEquationSeparatei(GLuint, GLenum, GLenum) {} + void StubBlendColor(GLfloat, GLfloat, GLfloat, GLfloat) {} + void StubDepthFunc(GLenum) {} + void StubDepthMask(GLboolean) {} + void StubDepthRangef(GLfloat, GLfloat) {} + void StubStencilFuncSeparate(GLenum, GLenum, GLint, GLuint) {} + void StubStencilMaskSeparate(GLenum, GLuint) {} + void StubStencilOpSeparate(GLenum, GLenum, GLenum, GLenum) {} + void StubClearColor(GLfloat, GLfloat, GLfloat, GLfloat) {} + void StubClearDepthf(GLfloat) {} + void StubClearStencil(GLint) {} + void StubCullFace(GLenum) {} + void StubFrontFace(GLenum) {} + void StubPolygonOffset(GLfloat, GLfloat) {} + void StubLineWidth(GLfloat) {} + void StubSampleCoverage(GLfloat, GLboolean) {} + + // Replaces the ES function table with no-ops that record only what these cases assert on. + // The table is ZEROED first on purpose: SyncRenderState is long, and a call it makes that + // this fixture did not anticipate must crash here rather than silently reach a stale pointer + // into a driver that this process never made current. + class ScopedRenderStateDriverStubs { + public: + ScopedRenderStateDriverStubs(): + 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{}; + gl.glViewport = StubViewport; + gl.glScissor = StubScissor; + gl.glEnable = StubEnable; + gl.glDisable = StubDisable; + gl.glEnablei = StubEnablei; + gl.glDisablei = StubDisablei; + gl.glBlendFuncSeparate = StubBlendFuncSeparate; + gl.glBlendFuncSeparatei = StubBlendFuncSeparatei; + gl.glBlendEquationSeparate = StubBlendEquationSeparate; + gl.glBlendEquationSeparatei = StubBlendEquationSeparatei; + gl.glBlendColor = StubBlendColor; + gl.glDepthFunc = StubDepthFunc; + gl.glDepthMask = StubDepthMask; + gl.glDepthRangef = StubDepthRangef; + gl.glStencilFuncSeparate = StubStencilFuncSeparate; + gl.glStencilMaskSeparate = StubStencilMaskSeparate; + gl.glStencilOpSeparate = StubStencilOpSeparate; + gl.glClearColor = StubClearColor; + gl.glClearDepthf = StubClearDepthf; + gl.glClearStencil = StubClearStencil; + gl.glCullFace = StubCullFace; + gl.glFrontFace = StubFrontFace; + gl.glPolygonOffset = StubPolygonOffset; + gl.glLineWidth = StubLineWidth; + gl.glSampleCoverage = StubSampleCoverage; + gl.glColorMask = StubColorMask; + gl.glColorMaski = StubColorMaski; + + auto& caps = MG_Backend::DirectGLES::g_GLESCapabilities; + caps.SupportsIndexedColorMask = true; + caps.SupportsSrgbWriteControl = false; + caps.SupportsPolygonMode = false; + caps.SupportsDualSourceBlend = true; + + ResetRecordedColorMasks(); + // 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); + MG_Impl::GLImpl::Scissor(0, 0, 4, 4); + MG_Backend::DirectGLES::RenderStateImpl::InvalidateSyncedRenderState(); + } + + ~ScopedRenderStateDriverStubs() { + MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 0; + MG_Backend::DirectGLES::g_GLESFuncs = m_funcs; + MG_Backend::DirectGLES::g_GLESCapabilities = m_caps; + // 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); + } + + private: + MG_External::GLESFunctionsTable m_funcs; + MG_External::GLESCapabilities m_caps; + }; +} // namespace + +TEST_F(FramebufferTest, WidenedDrawBufferIsIdentifiedPerDrawBufferSlotNotPerAttachmentPoint) { + ScopedBackendOverride backend(MakeUnique(/*substituted=*/true)); + + // Complementary's `composite` framebuffer again: draw buffer 0 is a natively renderable + // RGBA8, draw buffer 1 is the widened RGB8_SNORM. Only the second may be doctored. + GLuint framebuffer = 0; + GLuint colortex7 = 0; + GLuint colortex1 = 0; + MG_Impl::GLImpl::CreateFramebuffers(1, &framebuffer); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex7); + MG_Impl::GLImpl::CreateTextures(GL_TEXTURE_2D, 1, &colortex1); + MG_Impl::GLImpl::TextureStorage2D(colortex7, 1, GL_RGBA8, 4, 4); + MG_Impl::GLImpl::TextureStorage2D(colortex1, 1, GL_RGB8_SNORM, 4, 4); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT0, colortex7, 0); + MG_Impl::GLImpl::NamedFramebufferTexture(framebuffer, GL_COLOR_ATTACHMENT1, colortex1, 0); + + auto& framebufferObject = MG_State::pGLContext->GetFramebufferObject(framebuffer); + ASSERT_NE(framebufferObject, nullptr); + framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color0); + framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color1); + + EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject), + 1u << 1); + + // Swapping the draw-buffer array moves the bit with the SLOT, not with the attachment point: + // glColorMaski and glClearBufferfv both address slots. + framebufferObject->SetDrawBuffer(0, FramebufferAttachmentType::Color1); + framebufferObject->SetDrawBuffer(1, FramebufferAttachmentType::Color0); + EXPECT_EQ(MG_Backend::DirectGLES::FramebufferImpl::ComputeAlphaWidenedDrawBufferMask(*framebufferObject), + 1u << 0); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWritesMaskedOff) { + ScopedRenderStateDriverStubs driver; + MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1; + + // What the application asked for: write every channel of every draw buffer. + MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + + // What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha. + ASSERT_TRUE(g_driverIndexedColorMasks[0].seen); + EXPECT_EQ(g_driverIndexedColorMasks[0].r, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[0].g, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[0].b, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_TRUE); + ASSERT_TRUE(g_driverIndexedColorMasks[1].seen); + EXPECT_EQ(g_driverIndexedColorMasks[1].r, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[1].g, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[1].b, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "a widened draw buffer must not take alpha writes"; + + // And what the application sees back. The doctoring lives entirely on the push; the frontend + // state it is derived from is never written, so glGet still answers with the app's value. + GLboolean appMask[4] = {GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE}; + MG_Impl::GLImpl::GetBooleanv(GL_COLOR_WRITEMASK, appMask); + EXPECT_EQ(appMask[0], GL_TRUE); + EXPECT_EQ(appMask[1], GL_TRUE); + EXPECT_EQ(appMask[2], GL_TRUE); + EXPECT_EQ(appMask[3], GL_TRUE) << "glGet(GL_COLOR_WRITEMASK) must report the application's mask"; + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitutesOne) { + ScopedRenderStateDriverStubs driver; + MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1; + MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + + // A draw first, so the mask really is doctored when the clear arrives... + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE); + + // ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's + // render-state version has not moved, so only the purpose-aware memo can force this push - + // without it the clear would inherit the draw's alpha-off mask and never write the 1.0. + ResetRecordedColorMasks(); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true); + ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask"; + EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha"; + + // The value that clear writes: the application's RGB, alpha replaced by the 1.0 the + // three-channel format implies, and only on the widened buffer. + const GLfloat appColor[4] = {0.25f, 0.5f, 0.75f, 0.0f}; + GLfloat scratch[4] = {}; + const GLfloat* widened = + MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, true, 1.0f, scratch); + EXPECT_EQ(widened[0], 0.25f); + EXPECT_EQ(widened[1], 0.5f); + EXPECT_EQ(widened[2], 0.75f); + EXPECT_EQ(widened[3], 1.0f); + + const GLfloat* untouched = + MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha(appColor, false, 1.0f, scratch); + EXPECT_EQ(untouched, appColor) << "a native attachment's clear must not even be copied"; + + // An integer widened format (GL_RGB8UI -> GL_RGBA8UI) carries the INTEGER one, not a + // saturated field: glClearBufferuiv takes the value verbatim. + const GLuint appIntegerColor[4] = {7u, 8u, 9u, 0u}; + GLuint integerScratch[4] = {}; + const GLuint* widenedInteger = MG_Backend::DirectGLES::FramebufferImpl::SubstituteWidenedClearAlpha( + appIntegerColor, true, GLuint(1), integerScratch); + EXPECT_EQ(widenedInteger[3], 1u); + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} + +TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffer) { + // The doctoring only ever REMOVES alpha writes on a widened buffer; it must never add them + // back on a buffer the application masked itself, and must never touch a native one. + ScopedRenderStateDriverStubs driver; + MG_Backend::DirectGLES::FramebufferImpl::g_alphaWidenedDrawBufferMask = 1u << 1; + + MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); + MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + + EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives"; + EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE) << "the widened buffer loses alpha"; + EXPECT_EQ(g_driverIndexedColorMasks[2].r, GL_FALSE); + EXPECT_EQ(g_driverIndexedColorMasks[2].g, GL_TRUE); + EXPECT_EQ(g_driverIndexedColorMasks[2].b, GL_FALSE); + EXPECT_EQ(g_driverIndexedColorMasks[2].a, GL_TRUE) << "a native buffer keeps its alpha writes"; + EXPECT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); +} diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index a4d5004b..70ef7da7 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -2885,3 +2887,292 @@ TEST_F(TextureTest, NamedTextureCallKeepsUnitBindingAccountingCoherent) { MG_Impl::GLImpl::DeleteTextures(2, names); DrainPendingGlErrors(); } + +// ---- Three-channel colour-renderable widening (Complementary Reimagined / Iris) ---------------- +// +// No real OpenGL ES driver renders to a three-channel image, so a colour attachment the +// application asked for as GL_RGB8_SNORM or GL_RGB16F has to be stored in the four-channel +// sibling. The bit that says so used to be reachable for multisample storage only, which is why +// an ordinary GL_TEXTURE_2D attachment in one of those formats had no fallback at all and the +// frontend could only answer GL_FRAMEBUFFER_UNSUPPORTED. + +TEST_F(TextureTest, ColorAttachableTargetsRequestTheThreeChannelWidening) { + using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions; + using MobileGL::MG_Backend::DirectGLES::TextureImpl::TargetRequiresRenderableFormat; + + MG_External::GLESCapabilities capabilities{}; + capabilities.SupportsRenderSnorm = true; + capabilities.SupportsNorm16Texture = true; + + // Every image that can be a colour attachment, not just the multisample pair: an ordinary 2D + // texture is what Iris attaches, and it used to be excluded. + for (const TextureTarget target : {TextureTarget::Texture2D, TextureTarget::Texture3D, + TextureTarget::TextureCubeMap, TextureTarget::Texture2DArray, + TextureTarget::TextureCubeMapArray, TextureTarget::Texture2DMultisample, + TextureTarget::Texture2DMultisampleArray, TextureTarget::Texture1D, + TextureTarget::Texture1DArray, TextureTarget::TextureRectangle}) { + const SizeT targetIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(target); + EXPECT_TRUE(TargetRequiresRenderableFormat(targetIndex)) + << "target " << MG_Util::ConvertTextureTargetToString(target); + EXPECT_TRUE(GetRenderTargetNormalizeOptions(capabilities, targetIndex) & + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) + << "target " << MG_Util::ConvertTextureTargetToString(target); + } + // A renderbuffer exists only to be attached. + EXPECT_TRUE(TargetRequiresRenderableFormat(MobileGL::MG_Backend::GetRenderbufferFormatCapabilityTargetIndex())); + + // A buffer texture is the one image that can never be an attachment; its storage belongs to + // the buffer object, so widening it would misdescribe the application's data. + const SizeT bufferIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::TextureBuffer); + EXPECT_FALSE(TargetRequiresRenderableFormat(bufferIndex)); + EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, bufferIndex)); + + // Without EXT_render_snorm a 16-bit SNORM render target cannot keep its encoding either. + MG_External::GLESCapabilities noSnormCapabilities{}; + const SizeT texture2DIndex = MobileGL::MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D); + EXPECT_TRUE(GetRenderTargetNormalizeOptions(noSnormCapabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); + EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); +} + +TEST_F(TextureTest, ThreeChannelRenderTargetOptionAppliesToEveryDeniedThreeChannelFormat) { + using MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions; + const Flags requested = + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; + + // GL_RGB16F in particular matched no case at all, so no option could ever apply to it and it + // fell through NormalizePixelFormat's default passthrough unchanged. + for (const GLenum internalFormat : {GL_RGB8_SNORM, GL_RGB16_SNORM, GL_RGB16, GL_RGB10, GL_RGB12, GL_RGB16F, + GL_RGB32F, GL_SRGB8, GL_RGB8I, GL_RGB8UI, GL_RGB16I, GL_RGB16UI, GL_RGB32I, + GL_RGB32UI}) { + EXPECT_TRUE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) & + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + } + + // Four-channel and shared-exponent formats are not widened: RGBA8_SNORM has its own always-on + // fallback, and GL_RGB9_E5 has no four-channel sibling that would not need the shared exponent + // unpacked on every transfer (nothing renders to it on desktop GL either). + for (const GLenum internalFormat : {GL_RGBA8_SNORM, GL_RGBA16F, GL_RGBA8, GL_RGB8, GL_RGB9_E5}) { + EXPECT_FALSE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested) & + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + } +} + +TEST_F(TextureTest, ThreeChannelWideningRetargetsInternalFormatAndTransferPairTogether) { + using MG_Util::TextureFormatProcessor::NormalizePixelFormat; + struct Case { + GLenum requested; + Flags options; + GLenum internalFormat; + GLenum format; + GLenum type; + }; + const Flags widen = PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; + const Flags widenNoSnorm16 = + PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget | + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; + + const Case cases[] = { + // Complementary's colortex1 and colortex2. The transfer pair used to stay three-channel + // and keep the *source* component type, emitting (GL_RGBA16F, GL_RGB, GL_BYTE) - which ES + // rejects for glTexImage2D outright, and which only went unnoticed because the bit was + // reachable for multisample storage alone (glTexStorage*Multisample takes no pair). + {GL_RGB8_SNORM, widen, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + {GL_RGB16F, widen, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT}, + {GL_RGB32F, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + // 16-bit SNORM keeps its encoding where EXT_render_snorm can render to it; a half float's + // 11-bit mantissa cannot represent a 16-bit SNORM channel exactly. + {GL_RGB16_SNORM, widen, GL_RGBA16_SNORM, GL_RGBA, GL_SHORT}, + {GL_RGB16_SNORM, widenNoSnorm16, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + // 16-bit UNORM and the legacy 10/12-bit formats stored as RGB16. + {GL_RGB16, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + {GL_RGB10, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + {GL_RGB12, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + // sRGB and the integer formats: the base format has to move to the four-channel one of the + // right class, GL_RGBA_INTEGER included. + {GL_SRGB8, widen, GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE}, + {GL_RGB8I, widen, GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE}, + {GL_RGB8UI, widen, GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE}, + {GL_RGB16I, widen, GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT}, + {GL_RGB16UI, widen, GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT}, + {GL_RGB32I, widen, GL_RGBA32I, GL_RGBA_INTEGER, GL_INT}, + {GL_RGB32UI, widen, GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT}, + // The widening outranks the other fallbacks, which all pick a three-channel storage the + // driver still refuses to render to (GL_RGB8_SNORM -> GL_RGB16F, GL_RGB16 -> GL_RGB32F). + {GL_RGB8_SNORM, widen | PixelFormatNormalizeOptionBit::NoSnorm8, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + {GL_RGB16, widen | PixelFormatNormalizeOptionBit::NoNorm16, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + // Control: without the bit nothing moves. The bit is only ever set for a target whose + // native probe failed, so this is the shape every driver that does render to the + // three-channel form keeps - per format, not per platform (llvmpipe renders to GL_RGB16F + // but not to GL_RGB8_SNORM, GL_SRGB8, GL_RGB32F or the RGB integer formats). + {GL_RGB8_SNORM, PixelFormatNormalizeOptionBit::None, GL_RGB8_SNORM, GL_RGB, GL_BYTE}, + {GL_RGB16F, PixelFormatNormalizeOptionBit::None, GL_RGB16F, GL_RGB, GL_HALF_FLOAT}, + {GL_RGB32F, PixelFormatNormalizeOptionBit::None, GL_RGB32F, GL_RGB, GL_FLOAT}, + {GL_SRGB8, PixelFormatNormalizeOptionBit::None, GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE}, + // Not widened even under the bit: no four-channel shared-exponent sibling exists. + {GL_RGB9_E5, widen, GL_RGB9_E5, GL_RGB, GL_UNSIGNED_INT_5_9_9_9_REV}, + // Four-channel formats are unaffected by the bit; RGBA8_SNORM keeps its own fallback. + {GL_RGBA8_SNORM, widen, GL_RGBA8_SNORM, GL_RGBA, GL_BYTE}, + {GL_RGBA8_SNORM, widen | PixelFormatNormalizeOptionBit::NoRGBA8Snorm, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + }; + + for (const auto& testCase : cases) { + GLenum internalFormat = 0; + GLenum format = 0; + GLenum type = 0; + NormalizePixelFormat(testCase.requested, testCase.options, &internalFormat, &format, &type); + EXPECT_EQ(internalFormat, testCase.internalFormat) << "requested 0x" << std::hex << testCase.requested; + EXPECT_EQ(format, testCase.format) << "requested 0x" << std::hex << testCase.requested; + EXPECT_EQ(type, testCase.type) << "requested 0x" << std::hex << testCase.requested; + } +} + +TEST_F(TextureTest, WidenedRenderTargetUploadExpandsThreeChannelDataWithOpaqueAlpha) { + using MobileGL::MG_Backend::DirectGLES::TextureImpl::GetWidenableClientComponentCount; + using MobileGL::MG_Backend::DirectGLES::TextureImpl::PrepareChannelWidenedUpload; + + // Only the three-channel formats that can be widened report a source component count; the + // repack is what keeps the driver from walking three texels' worth of data per four-texel row. + for (const TextureInternalFormat format : + {TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F, + TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16, TextureInternalFormat::SRGB8, + TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB32I}) { + EXPECT_EQ(GetWidenableClientComponentCount(format), 3u) + << MG_Util::ConvertTextureInternalFormatToString(format); + } + EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8), 0u); + EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGBA8Snorm), 0u); + EXPECT_EQ(GetWidenableClientComponentCount(TextureInternalFormat::RGB9E5), 0u); + + const IntVec3 texelSize(2, 1, 1); + + // GL_RGB8_SNORM -> GL_RGBA16F: PrepareNormFloatFallbackUpload has already turned the Int8 + // shadow into floats, so what arrives here is three floats per texel. + { + const Float source[] = {0.25f, -0.5f, 0.75f, -1.0f, 0.0f, 1.0f}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_FLOAT, widened)); + ASSERT_NE(result, static_cast(source)); + ASSERT_EQ(widened.size(), 8 * sizeof(Float)); + const Float expected[] = {0.25f, -0.5f, 0.75f, 1.0f, -1.0f, 0.0f, 1.0f, 1.0f}; + for (SizeT i = 0; i < 8; ++i) { + EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i; + } + } + + // GL_RGB16F -> GL_RGBA16F uploads halves untouched, so the synthetic alpha is the half + // encoding of 1.0 rather than a saturated field. + { + const Uint16 source[] = {0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_HALF_FLOAT, widened)); + ASSERT_NE(result, static_cast(source)); + const Uint16 expected[] = {0x0001, 0x0002, 0x0003, 0x3C00, 0x0004, 0x0005, 0x0006, 0x3C00}; + for (SizeT i = 0; i < 8; ++i) { + EXPECT_EQ(result[i], expected[i]) << "component " << i; + } + } + + // GL_SRGB8 -> GL_SRGB8_ALPHA8: fixed-point one is the saturated field. + { + const Uint8 source[] = {1, 2, 3, 4, 5, 6}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened)); + const Uint8 expected[] = {1, 2, 3, 0xFF, 4, 5, 6, 0xFF}; + ASSERT_NE(result, static_cast(source)); + EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0); + } + + // GL_RGB16_SNORM -> GL_RGBA16_SNORM keeps GL_SHORT, whose 1.0 is the positive maximum. + { + const Int16 source[] = {-1, 2, -3, 4, -5, 6}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_SHORT, widened)); + const Int16 expected[] = {-1, 2, -3, 0x7FFF, 4, -5, 6, 0x7FFF}; + ASSERT_NE(result, static_cast(source)); + EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0); + } + + // An integer format's added channel carries the integer one, not a saturated field. + { + const Uint32 source[] = {10, 20, 30, 40, 50, 60}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_UNSIGNED_INT, widened, /*integerData=*/true)); + const Uint32 expected[] = {10, 20, 30, 1, 40, 50, 60, 1}; + ASSERT_NE(result, static_cast(source)); + EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0); + } + + // GL_RGB8I -> GL_RGBA8I uploads as GL_BYTE, the very type GL_RGB8_SNORM uses, so the type + // alone cannot decide the added channel's value: the integer format's one is 1, the + // signed-normalized format's is 0x7F. Getting this wrong is invisible through sampling and + // glGetTexImage (both answer the alpha with the format's implied one) but escapes through a + // blit or glCopyTexSubImage out of the widened attachment. + { + const Int8 source[] = {-1, 2, -3, 4, -5, 6}; + Vector widened; + const auto* asInteger = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_BYTE, widened, /*integerData=*/true)); + const Int8 expectedInteger[] = {-1, 2, -3, 1, 4, -5, 6, 1}; + ASSERT_NE(asInteger, static_cast(source)); + EXPECT_EQ(std::memcmp(asInteger, expectedInteger, sizeof(expectedInteger)), 0); + + Vector widenedNorm; + const auto* asNormalized = static_cast(PrepareChannelWidenedUpload( + 3, texelSize, source, sizeof(source), GL_BYTE, widenedNorm, /*integerData=*/false)); + const Int8 expectedNormalized[] = {-1, 2, -3, 0x7F, 4, -5, 6, 0x7F}; + EXPECT_EQ(std::memcmp(asNormalized, expectedNormalized, sizeof(expectedNormalized)), 0); + } + + // Which class a widenable format belongs to. + for (const TextureInternalFormat format : + {TextureInternalFormat::RGB8I, TextureInternalFormat::RGB8UI, TextureInternalFormat::RGB16I, + TextureInternalFormat::RGB16UI, TextureInternalFormat::RGB32I, TextureInternalFormat::RGB32UI}) { + EXPECT_TRUE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format)) + << MG_Util::ConvertTextureInternalFormatToString(format); + } + for (const TextureInternalFormat format : + {TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16Snorm, TextureInternalFormat::RGB16, + TextureInternalFormat::RGB16F, TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8}) { + EXPECT_FALSE(MobileGL::MG_Backend::DirectGLES::TextureImpl::IsIntegerWidenableFormat(format)) + << MG_Util::ConvertTextureInternalFormatToString(format); + } + + // The destination is sized from the level, never from the source. The driver reads a full + // width*height*4 components for the transfer it was handed, so a short source must still + // leave a full buffer behind - sizing it from the source would hand the driver a buffer it + // runs off the end of. + { + const Float shortSource[] = {0.5f, 0.25f, 0.125f}; + Vector widened; + const auto* result = static_cast(PrepareChannelWidenedUpload( + 3, IntVec3(2, 2, 1), shortSource, sizeof(shortSource), GL_FLOAT, widened)); + ASSERT_NE(result, static_cast(shortSource)); + ASSERT_EQ(widened.size(), 4 * 4 * sizeof(Float)); + const Float expected[] = {0.5f, 0.25f, 0.125f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f}; + for (SizeT i = 0; i < 16; ++i) { + EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i; + } + } + + // No widening in effect (or nothing to convert): the caller's pointer comes straight back, so + // the sub-rect upload fast path still recognises an unconverted level. + { + const Float source[] = {1.0f, 2.0f, 3.0f, 4.0f}; + Vector widened; + EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_FLOAT, widened), + static_cast(source)); + EXPECT_EQ(PrepareChannelWidenedUpload(0, texelSize, source, sizeof(source), GL_FLOAT, widened), + static_cast(source)); + EXPECT_EQ(PrepareChannelWidenedUpload(3, texelSize, nullptr, 0, GL_FLOAT, widened), nullptr); + } +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index a97a8de1..25a82f0b 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -838,6 +838,12 @@ namespace MobileGL::MG_Util::BackendLoader { if (std::strcmp(extension, "GL_EXT_render_snorm") == 0) { caps.SupportsRenderSnorm = true; } + if (std::strcmp(extension, "GL_EXT_color_buffer_float") == 0) { + caps.SupportsColorBufferFloat = true; + } + if (std::strcmp(extension, "GL_EXT_color_buffer_half_float") == 0) { + caps.SupportsColorBufferHalfFloat = true; + } if (std::strcmp(extension, "GL_EXT_sRGB_write_control") == 0) { caps.SupportsSrgbWriteControl = true; } diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index dea4eb24..4c8976ca 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1034,6 +1034,16 @@ namespace MobileGL { // GL_EXT_render_snorm is present, so the signed-normalized formats are colour-renderable // (and usable as multisample texture storage) rather than texture-only. Bool SupportsRenderSnorm = false; + // GL_EXT_color_buffer_float is present, so GL_RGBA16F / GL_RGBA32F / GL_R11F_G11F_B10F + // (and the R/RG float formats) are colour-renderable. ES 3.x core makes them + // texture-only, and every Iris shaderpack renders into at least R11F_G11F_B10F, so + // without this no shaderpack can work at all. + Bool SupportsColorBufferFloat = false; + // GL_EXT_color_buffer_half_float is present: the half-float subset of the above, for + // drivers that ship only the smaller extension. Note it does NOT rescue GL_RGB16F - + // the extension nominally lists it but disclaims it under ES 3.x, and real drivers + // reject it, which is why three-channel float attachments are widened instead. + Bool SupportsColorBufferHalfFloat = false; // GL_EXT_sRGB_write_control is present, so GL_FRAMEBUFFER_SRGB can be turned off. // GLES has no such switch in core: writes into an sRGB attachment are ALWAYS encoded, // while desktop GL leaves GL_FRAMEBUFFER_SRGB disabled by default and writes raw. diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 48990233..5bd3d13a 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -16,7 +16,12 @@ // Only for the compile-time MAX_VERTEX_ATTRIBS constant asserted below. The POST still executes no // MG_State code: it runs standalone, before MG_State::Init(). #include +#include +#include +#include #include +#include +#include #include #include #include @@ -450,6 +455,35 @@ namespace MobileGL::MG_Util::SelfTest { builder.Warn("GL_EXT_texture_norm16", "not supported; 16-bit normalized texture formats need emulation"); } + if (caps.SupportsRenderSnorm) { + builder.Pass("GL_EXT_render_snorm", + "supported (signed-normalized formats are colour-renderable, so an " + "SNORM render target keeps its own encoding instead of a float substitute)"); + } else { + builder.Warn("GL_EXT_render_snorm", + "not supported; signed-normalized formats are texture-only, so every SNORM " + "render target is stored as a float (GL_RGBA8_SNORM/GL_RGB8_SNORM -> " + "GL_RGBA16F) and its fragment outputs are clamped to [-1,1] in software"); + } + // FAIL, not WARN: ES 3.x core makes every float format texture-only, and every Iris + // shaderpack renders into at least GL_R11F_G11F_B10F (Complementary's colortex0, BSL's + // colortex0). Without this extension there is no substitute format left - a half float + // is not renderable either - so shaderpacks cannot work at all on such a driver. + if (caps.SupportsColorBufferFloat) { + builder.Pass("GL_EXT_color_buffer_float", + "supported (GL_R11F_G11F_B10F / GL_RGBA16F / GL_RGBA32F are " + "colour-renderable, which is what every shaderpack renders into)"); + } else if (caps.SupportsColorBufferHalfFloat) { + builder.Warn("GL_EXT_color_buffer_float", + "not supported, but GL_EXT_color_buffer_half_float is; 16-bit float render " + "targets work, 32-bit float ones (GL_RGBA32F, and the GL_RGBA16 fallback " + "that lands on it) do not"); + } else { + builder.Fail("GL_EXT_color_buffer_float", + "not supported, and neither is GL_EXT_color_buffer_half_float; no floating-point " + "format is colour-renderable on this driver, so no shaderpack can create its " + "render targets (Iris reports GL_FRAMEBUFFER_UNSUPPORTED and refuses to load)"); + } // INFO, never WARN: this is the HOST driver's ability to compile its own ESSL on // its own threads, and MobileGL's asynchronous compilation does not depend on it @@ -783,6 +817,117 @@ namespace MobileGL::MG_Util::SelfTest { } } + // No real ES driver renders to a three-channel image, but desktop GL applications ask for + // one constantly - Complementary Reimagined's colortex1 is GL_RGB8_SNORM and its colortex2 + // is GL_RGB16F, and Iris refuses to load when a framebuffer built from them is not + // COMPLETE. DirectGLES substitutes the four-channel sibling, and this row names the + // outcome per format so the failure mode is a five-second read instead of an + // investigation. Answered from the capability cache that was just probed on this very + // driver, so it costs no extra GL work. + void ReportThreeChannelColorAttachments(ReportBuilder& builder, const MG_External::GLESCapabilities& caps, + const MG_Backend::FormatCapabilityCache& cache) { + // GL_RGB8 is the control: it is ES-core renderable, and it is exactly why BSL loads on + // the same driver where Complementary does not. The rest are one representative of + // each widening class - signed-normalized, half float, 32-bit float, sRGB, integer - + // so the row says which CLASS of shaderpack target a device cannot serve rather than + // just "three-channel formats". + constexpr TextureInternalFormat kProbedFormats[] = { + TextureInternalFormat::RGB8, TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F, + TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8, TextureInternalFormat::RGB8UI}; + const SizeT targetIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D); + const Flags renderTargetOptions = + MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions(caps, targetIndex); + + String nativeList; + String widenedList; + String unusableList; + // GL_RGB8 is colour-renderable in ES 3.0 CORE. A driver that answers no to it is + // broken (or the probe itself is), and that is the ONLY three-channel verdict that + // deserves a FAIL on its own - see the verdict block below. + Bool controlFormatBroken = false; + const auto append = [](String& list, const String& entry) { + if (!list.empty()) list += ", "; + list += entry; + }; + + for (const TextureInternalFormat probedFormat : kProbedFormats) { + const SizeT formatIndex = static_cast(probedFormat); + const String name = MG_Util::ConvertTextureInternalFormatToString(probedFormat); + if (MG_Backend::HasFormatCapability(cache.FullCaps[targetIndex][formatIndex], + MG_Backend::FormatCapability::FramebufferRenderable)) { + append(nativeList, name); + continue; + } + if (probedFormat == TextureInternalFormat::RGB8) { + controlFormatBroken = true; + } + if (MG_Backend::HasFormatCapability(cache.CaveatCaps[targetIndex][formatIndex], + MG_Backend::FormatCapability::FramebufferRenderable)) { + GLenum widenedInternalFormat = GL_UNKNOWN_MGL; + MG_Util::TextureFormatProcessor::NormalizePixelFormat( + MG_Util::ConvertTextureInternalFormatToGLEnum(probedFormat), renderTargetOptions, + &widenedInternalFormat, nullptr, nullptr); + append(widenedList, name + " -> " + MG_Util::ConvertGLEnumToString(widenedInternalFormat)); + continue; + } + append(unusableList, name); + } + + String detail; + if (!nativeList.empty()) detail += "renderable natively: " + nativeList; + if (!widenedList.empty()) { + if (!detail.empty()) detail += "; "; + detail += "widened to stay renderable: " + widenedList; + } + if (!unusableList.empty()) { + if (!detail.empty()) detail += "; "; + detail += "NOT renderable and not substitutable: " + unusableList; + } + + // The verdict deliberately does NOT track "every probed format came out usable". + // + // GL_RGB32F widens to GL_RGBA32F, and GL_RGBA32F is colour-renderable only under + // GL_EXT_color_buffer_float. A perfectly healthy half-float-only driver (the common + // mobile shape: EXT_color_buffer_half_float and nothing more) therefore reports + // GL_RGB32F as unusable while every format a shaderpack actually renders into works. + // FAILing that device would make the POST's hardest verdict fire on a configuration + // MobileGL runs fine on, which is exactly how a report stops being read. + // + // So FAIL is reserved for the two answers that really are broken: + // * the ES-core control (GL_RGB8) is not renderable - the probe or the driver is + // wrong about something much more basic than three-channel widening; and + // * a widenable format has no usable fallback ON A DRIVER THAT ADVERTISES + // GL_EXT_color_buffer_float - the extension promises the widened float targets + // are renderable, so a gap here is a real, unexplained refusal. + // Everything else is a WARN carrying the exact per-format status, which is what the + // row is for. The "no float render targets at all" case is already a FAIL of its own + // on the GL_EXT_color_buffer_float row above; repeating it here would only double-count. + if (controlFormatBroken) { + builder.Fail("Three-channel colour attachments", + detail + " - GL_RGB8 is colour-renderable in OpenGL ES 3.0 core, so a driver " + "that refuses it cannot render to ANY three-channel attachment and the " + "capability probe itself is suspect"); + } else if (!unusableList.empty() && caps.SupportsColorBufferFloat) { + builder.Fail("Three-channel colour attachments", + detail + " - GL_EXT_color_buffer_float is supported, so the widened " + "four-channel float targets are required to be renderable; a framebuffer " + "using one of the formats above still reports GL_FRAMEBUFFER_UNSUPPORTED, " + "which Iris turns into a hard load failure"); + } else if (!unusableList.empty()) { + builder.Warn("Three-channel colour attachments", + detail + " - without GL_EXT_color_buffer_float the 32-bit float widening has no " + "renderable target left, so a shaderpack asking for one of the formats " + "above gets GL_FRAMEBUFFER_UNSUPPORTED; the half-float and fixed-point " + "ones above still work"); + } else if (!widenedList.empty()) { + builder.Warn("Three-channel colour attachments", + detail + " - the substitution costs the extra alpha channel's memory and is " + "hidden from the application by an ALPHA->ONE swizzle"); + } else { + builder.Pass("Three-channel colour attachments", detail); + } + } + // Everything the "MobileGL reported ..." rows need from the GLES device probe. struct GlesProbeSummary { Bool capsValid = false; @@ -913,6 +1058,7 @@ namespace MobileGL::MG_Util::SelfTest { builder.report.formatCapabilities.emplace(); MG_Backend::DirectGLES::PopulateFormatCapabilities( glesFuncs, caps, builder.report.formatCapabilities.value()); + ReportThreeChannelColorAttachments(builder, caps, builder.report.formatCapabilities.value()); } while (false); } diff --git a/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp b/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp index 800aaeab..c56dd35d 100644 --- a/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp +++ b/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp @@ -58,12 +58,99 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { case GL_R8_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8; break; + // The rest of the three-channel formats no real ES driver renders to. They have no + // other fallback: none of the driver/forced option bits names them, so before the + // render-target widening existed for ordinary targets an FBO attachment in one of + // them could only ever be answered GL_FRAMEBUFFER_UNSUPPORTED (Complementary + // Reimagined's colortex2 = RGB16F). + // + // GL_RGB9_E5 is deliberately absent: its four-channel sibling would have to be a + // half float, which means unpacking the shared exponent on every transfer, and + // nothing renders to a shared-exponent format on desktop GL either. + case GL_RGB16F: + case GL_RGB32F: + case GL_SRGB8: + case GL_RGB8I: + case GL_RGB8UI: + case GL_RGB16I: + case GL_RGB16UI: + case GL_RGB32I: + case GL_RGB32UI: + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; + break; default: break; } return applicableOptions; } + namespace { + // The four-channel sibling a three-channel format is widened to when the target has to + // stay colour-renderable, together with the transfer pair that describes client data for + // it. Kept in one place because all three of NormalizePixelFormat's switches have to agree: + // reporting the widened storage but the original three-channel base format emitted + // inconsistent triples such as (GL_RGBA16F, GL_RGB, GL_BYTE), which is + // GL_INVALID_OPERATION for glTexImage2D on ES. That only ever went unnoticed because the + // bit was reachable for multisample storage alone, and glTexStorage*Multisample takes no + // transfer pair at all. + struct ThreeChannelWidening { + GLenum InternalFormat = GL_UNKNOWN_MGL; + GLenum Format = GL_UNKNOWN_MGL; + GLenum Type = GL_UNKNOWN_MGL; + + explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; } + }; + + ThreeChannelWidening GetThreeChannelRenderTargetWidening(GLenum internalFormat, + Flags options) { + if (!(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) { + return {}; + } + switch (internalFormat) { + // Signed-normalized: matches what the always-on NoRGBA8Snorm fallback already does to + // GL_RGBA8_SNORM, so the two SNORM8 formats land on the same storage. + case GL_RGB8_SNORM: + return {GL_RGBA16F, GL_RGBA, GL_FLOAT}; + case GL_RGB16_SNORM: + // A half float loses the low bits of a 16-bit SNORM channel, so keep the + // signed-normalized encoding whenever the driver can render to it. + return (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) + ? ThreeChannelWidening{GL_RGBA16F, GL_RGBA, GL_FLOAT} + : ThreeChannelWidening{GL_RGBA16_SNORM, GL_RGBA, GL_SHORT}; + // Unsigned-normalized 16-bit (and the legacy 10/12-bit formats stored as RGB16): + // GL_RGB32F is a legal ES texture format but is not colour-renderable either. + case GL_RGB16: + case GL_RGB10: + case GL_RGB12: + return {GL_RGBA32F, GL_RGBA, GL_FLOAT}; + // Floating point. + case GL_RGB16F: + return {GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT}; + case GL_RGB32F: + return {GL_RGBA32F, GL_RGBA, GL_FLOAT}; + // sRGB: GL_SRGB8_ALPHA8 keeps the sRGB encoding of the colour channels and stores + // the added alpha linearly, which is exactly the three-channel format's semantics. + case GL_SRGB8: + return {GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE}; + // Integer. + case GL_RGB8I: + return {GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE}; + case GL_RGB8UI: + return {GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE}; + case GL_RGB16I: + return {GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT}; + case GL_RGB16UI: + return {GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT}; + case GL_RGB32I: + return {GL_RGBA32I, GL_RGBA_INTEGER, GL_INT}; + case GL_RGB32UI: + return {GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT}; + default: + return {}; + } + } + } // namespace + void NormalizePixelFormat(GLenum internalFormat, Flags options, GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType) { #ifdef TRACY_ENABLE @@ -95,13 +182,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_RGB16: - if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { - // GL_RGB32F is a legal ES texture format but is not colour-renderable, so - // glTexStorage2DMultisample rejects it and the attachment ends up with no - // storage at all. - *outInternalFormat = GL_RGBA32F; - break; - } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoRgb16)) { *outInternalFormat = GL_RGB32F; @@ -132,14 +212,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_RGB16_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { - // A half float loses the low bits of a 16-bit SNORM channel, so keep the - // signed-normalized encoding whenever the driver can render to it. - *outInternalFormat = (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) - ? GL_RGBA16F - : GL_RGBA16_SNORM; - break; - } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) || (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { @@ -173,10 +245,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_RGB8_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { - *outInternalFormat = GL_RGBA16F; - break; - } if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { *outInternalFormat = GL_RGB16F; break; @@ -615,5 +683,18 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { break; } } + + // Applied last, over whatever the three switches above chose: widening a three-channel + // format to keep a colour attachment renderable outranks every other fallback, because + // the others all pick a three-channel storage the driver still refuses to render to + // (GL_RGB8_SNORM -> GL_RGB16F under NoSnorm8, GL_RGB16 -> GL_RGB32F under NoNorm16). + // All three outputs move together: reporting the widened storage while leaving the + // three-channel base format and its component type in place produced triples like + // (GL_RGBA16F, GL_RGB, GL_BYTE), which ES rejects for glTexImage2D outright. + if (const ThreeChannelWidening widening = GetThreeChannelRenderTargetWidening(internalFormat, options)) { + if (outInternalFormat) *outInternalFormat = widening.InternalFormat; + if (outFormat) *outFormat = widening.Format; + if (outType) *outType = widening.Type; + } } } // namespace MobileGL::MG_Util::TextureFormatProcessor diff --git a/MobileGL/MG_Util/Texture/TextureFormatProcessor.h b/MobileGL/MG_Util/Texture/TextureFormatProcessor.h index c5fcd9fa..f3de4163 100644 --- a/MobileGL/MG_Util/Texture/TextureFormatProcessor.h +++ b/MobileGL/MG_Util/Texture/TextureFormatProcessor.h @@ -20,9 +20,14 @@ namespace MobileGL { NoRGB16Snorm = 1 << 6, // The target must be colour-renderable and ES has no renderable three-channel // form of the requested format, so it has to be widened to the four-channel one. - // Only meaningful for multisample textures: those can never be uploaded to, only - // rendered into, so the extra alpha comes from the draw (1.0 for an RGB source) - // and no transfer path has to expand three-channel client data. + // Set for any colour-attachable target whose native three-channel form the driver + // refused to render to (multisample storage always, since ES has no three-channel + // multisample format at all; every other target only after its native probe failed). + // The widening is visible to every transfer path, so it also retargets the (format, + // type) pair NormalizePixelFormat reports: the upload has to describe four + // components in the widened storage's component type, the backend has to expand + // three-channel client data with an alpha of 1.0, and sampling/readback has to hide + // the added alpha again (BackendTextureFormatAddsAlpha). NoThreeChannelRenderTarget = 1 << 7, // Pairs with the bit above: the widened four-channel format has to stay renderable AND // keep 16-bit signed-normalized precision, which needs both EXT_texture_norm16 and