diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 4b82ca40..a8fe42d3 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -7684,6 +7684,149 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } + // ---- Bit-exact readback of a 32-bit packed colour level --------------------------------------- + // + // glGetTexImage of a packed format read with its OWN client type owes the application the words + // the image HOLDS, and neither of the two routes above can promise that once anything other than + // a glTexImage has written the level: + // + // * the colour-attachment route reads GL_RGBA/GL_FLOAT and re-encodes, which canonicalizes an + // RGB9_E5 shared exponent (0xf8fc0000 -> 0xe7e00000, same value, different bits) and + // collapses an R11F_G11F_B10F NaN to the canonical payload 1 + // (MG_Util::EncodeFloatToUnsignedSmallFloat) - and a copy-image from RGB9_E5 lands exactly + // such a NaN in the 10-bit blue field every time, because the source's shared-exponent + // field is all ones; + // * the CPU shadow only ever holds what was UPLOADED, so for a level glCopyImageSubData wrote + // it answers with the PRE-COPY contents. MirrorCopyImageIntoDestinationShadow patches that + // up for the shapes it can address texel-exactly and declines for the rest - a renderbuffer + // source (which has no shadow to mirror from at all), a cube or 1D-array endpoint, a + // self-copy - and the decline is silent, so the stale words are served as truth. + // + // glCopyImageSubData is a raw texel-block move and EXT_copy_image puts every 32-bit colour + // format in one compatibility class, so copying the level into a scratch GL_R32UI image and + // reading THAT back as unsigned integers hands over the stored words themselves, whoever wrote + // them. This is what lets the shadow stop being the authority for these formats: it is tried + // first, and every step reports rather than guesses, so a driver that turns any of it down + // simply leaves the old shadow/attachment fallbacks to run. + static GLuint g_packedWordScratchTextureId = 0; + static GLsizei g_packedWordScratchWidth = 0; + static GLsizei g_packedWordScratchHeight = 0; + + // Grow-only, so a readback sweep over a mip chain allocates once. Zero when the driver refused + // the storage, which is a decline and not an error. + static GLuint EnsurePackedWordScratchTexture(GLsizei width, GLsizei height) { + if (g_packedWordScratchTextureId != 0 && g_packedWordScratchWidth >= width && + g_packedWordScratchHeight >= height) { + return g_packedWordScratchTextureId; + } + const GLsizei newWidth = std::max(width, g_packedWordScratchWidth); + const GLsizei newHeight = std::max(height, g_packedWordScratchHeight); + if (g_packedWordScratchTextureId != 0) { + // A scratch FBO may still name the old id, and the driver is free to hand the same + // number back for the replacement - which would false-skip the re-attach. + ScratchFBOImpl::NoteTextureIdDeleted(g_packedWordScratchTextureId); + g_GLESFuncs.glDeleteTextures(1, &g_packedWordScratchTextureId); + g_packedWordScratchTextureId = 0; + g_packedWordScratchWidth = 0; + g_packedWordScratchHeight = 0; + } + GLuint texture = 0; + g_GLESFuncs.glGenTextures(1, &texture); + if (texture == 0) return 0; + + ClearGLErrors(); + TextureImpl::ActivateTextureUnit(TextureImpl::TempTextureUnit); + g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, texture); + // Immutable single-level storage: glCopyImageSubData wants a complete image, and + // glTexStorage clamps TEXTURE_MAX_LEVEL, which is what makes a one-level texture complete + // under the default mipmapping filter. + g_GLESFuncs.glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, newWidth, newHeight); + const GLenum storageError = g_GLESFuncs.glGetError(); + // Re-bind whatever the binding cache says lives on the temp unit, so the cache stays + // truthful without a driver query (same discipline as CopyR32FTexture2D). + auto* cachedBound = TextureImpl::g_boundTexturesCache[TextureImpl::TempTextureUnit] + [static_cast(TextureTarget::Texture2D)]; + g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, cachedBound ? cachedBound->GetBackendTextureId() : 0); + if (storageError != GL_NO_ERROR) { + g_GLESFuncs.glDeleteTextures(1, &texture); + MGLOG_D("GetTexImage: no %dx%d GL_R32UI scratch image (%s); the verbatim word readback is unavailable", + newWidth, newHeight, MG_Util::ConvertGLEnumToString(storageError).c_str()); + return 0; + } + g_packedWordScratchTextureId = texture; + g_packedWordScratchWidth = newWidth; + g_packedWordScratchHeight = newHeight; + return texture; + } + + static void ReleasePackedWordScratchTexture() { + // The ES context (and the name with it) is gone; deleting here would target a recycled + // name in the successor context. + g_packedWordScratchTextureId = 0; + g_packedWordScratchWidth = 0; + g_packedWordScratchHeight = 0; + } + + // One slice of `backendTarget`'s level, as width*height stored 32-bit words in `outWords`. + static Bool ReadPackedLevelWordsViaScratch(GLuint texture, GLenum backendTarget, GLint level, GLint slice, + GLsizei width, GLsizei height, Uint32* outWords) { + if (texture == 0 || outWords == nullptr || width <= 0 || height <= 0 || level < 0 || slice < 0) return false; + if (!g_GLESFuncs.glCopyImageSubData) return false; + + // Horizontal bands, so neither the scratch image nor the staging buffer scales with the + // level. The scratch is grow-only on purpose - a sweep down a mip chain must not + // reallocate per level - which without a band cap would leave a 4096x4096 readback's + // 64 MiB image parked for the rest of the process. The cap is 1 MiB of GL_R32UI, with + // 4 MiB of staging behind it because the read lands four words per texel. + constexpr SizeT kMaxScratchTexels = SizeT{1} << 18; + const GLsizei bandRows = std::max( + 1, static_cast(std::min(kMaxScratchTexels / static_cast(width), + static_cast(height)))); + const GLuint scratch = EnsurePackedWordScratchTexture(width, bandRows); + if (scratch == 0) return false; + + ScopedFramebufferBinding readBinding(/*saveRead=*/true, /*saveDraw=*/false); + auto& scratchFB = ScratchFBOImpl::BlitReadFramebuffer(); + FramebufferImpl::BindFramebufferId(GL_READ_FRAMEBUFFER, ScratchFBOImpl::EnsureId(scratchFB)); + ScratchFBOImpl::EnsureColorAttachment2D(scratchFB, GL_READ_FRAMEBUFFER, scratch, GL_TEXTURE_2D, 0); + ScratchFBOImpl::EnsureReadBuffer(scratchFB, GL_COLOR_ATTACHMENT0); + if (g_GLESFuncs.glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { + MGLOG_D("GetTexImage: the GL_R32UI scratch attachment is incomplete; falling back"); + return false; + } + + // GL_RGBA_INTEGER/GL_UNSIGNED_INT is the one combination ES guarantees for an integer + // colour buffer, so the read lands four words per texel and the red one is compacted out + // here. The PACK scope is the tight default rather than the application's, so a row comes + // back packed at exactly `width * 4` words. One glGetError covers the whole loop: it + // accumulates, and a failure anywhere means the caller falls back rather than trusting a + // partial result. + const SizeT wordsPerRow = static_cast(width) * 4; + Vector staging(static_cast(bandRows) * wordsPerRow); + ScopedPixelPackBuffer packBuffer(0); + ScopedPackState packState(PixelStoreImpl::PackState{4, 0, 0, 0}); + ClearGLErrors(); + for (GLsizei y = 0; y < height; y += bandRows) { + const GLsizei rows = std::min(bandRows, height - y); + g_GLESFuncs.glCopyImageSubData(texture, backendTarget, level, 0, y, slice, scratch, GL_TEXTURE_2D, 0, 0, + 0, 0, width, rows, 1); + g_GLESFuncs.glReadPixels(0, 0, width, rows, GL_RGBA_INTEGER, GL_UNSIGNED_INT, staging.data()); + for (GLsizei row = 0; row < rows; ++row) { + const Uint32* srcRow = staging.data() + static_cast(row) * wordsPerRow; + Uint32* dstRow = outWords + static_cast(y + row) * static_cast(width); + for (GLsizei x = 0; x < width; ++x) dstRow[x] = srcRow[static_cast(x) * 4]; + } + } + const GLenum error = g_GLESFuncs.glGetError(); + if (error != GL_NO_ERROR) { + MGLOG_D("GetTexImage: the GL_R32UI word readback of %s was refused (%s); falling back", + MG_Util::ConvertGLEnumToString(backendTarget).c_str(), + MG_Util::ConvertGLEnumToString(error).c_str()); + return false; + } + return true; + } + static Bool IsLegacyNativeReadPixelsFormat(GLenum format) { return format == GL_RGBA || format == GL_RGBA_INTEGER || format == GL_RED || format == GL_RED_INTEGER || format == GL_DEPTH_COMPONENT || format == GL_STENCIL_INDEX || format == GL_DEPTH_STENCIL; @@ -8058,17 +8201,52 @@ namespace MobileGL::MG_Backend::DirectGLES { // value 8064, different words), and the conformance suite compares the words // ("CopyImageSubData modified contents of source image"). The scratch FBO does NOT // decide this for us: Adreno reports an RGB9_E5 colour attachment complete, so the - // shadow branch further down was unreachable. Serve the verbatim-word pairs from the - // shadow first and keep the GPU attempts as the fallback for a level the shadow never - // received. Every other format still prefers the GPU, so a rendered-into texture is - // unaffected; RGB9_E5 is not colour-renderable, so its shadow stays authoritative - - // and the one path that GPU-writes it, CopyImageSubData, mirrors itself into the - // shadow for exactly this reason. + // shadow branch further down was unreachable. Every other format still prefers the + // GPU, so a rendered-into texture is unaffected. + const Bool rawPackedWordRead = MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer( + textureObject->GetFormat(), MG_Util::ConvertGLEnumToTextureInputFormat(format), + MG_Util::ConvertGLEnumToTexturePixelDataType(type)); + // ...and the GPU CAN answer with the stored words after all, for any 32-bit packed + // format and whoever wrote the level, by going through a scratch GL_R32UI image (see + // ReadPackedLevelWordsViaScratch). Preferred over both routes below because it is the + // only one that is right for a level glCopyImageSubData wrote: the shadow may never + // have seen that write, and re-encoding the attachment cannot reproduce an RGB9_E5 + // shared exponent or an R11F_G11F_B10F NaN payload. A multisample image is excluded + // because copy-image requires matching sample counts. + if (rawPackedWordRead && textureObject->GetSamples() == 0) { + // Copy-image addresses a cube map as ONE image with the face on z, where + // glGetTexImage names the face in its target. + const auto readUploadTarget = MG_Util::ConvertGLEnumToTextureUploadTarget(target); + const GLint copyBaseSlice = + (readUploadTarget >= TextureUploadTarget::CubeMapPositiveX && + readUploadTarget <= TextureUploadTarget::CubeMapNegativeZ) + ? static_cast(readUploadTarget) - + static_cast(TextureUploadTarget::CubeMapPositiveX) + : 0; + const GLenum copyTarget = + TextureImpl::ConvertTextureTargetToBackendGLEnum(textureObject->GetTarget()); + const SizeT sliceWords = static_cast(size.x()) * static_cast(size.y()); + Vector words(sliceWords * static_cast(sliceCount)); + Bool allSlicesRead = true; + for (GLsizei slice = 0; slice < sliceCount && allSlicesRead; ++slice) { + allSlicesRead = ReadPackedLevelWordsViaScratch(backendTexId, copyTarget, level, + copyBaseSlice + slice, size.x(), size.y(), + words.data() + sliceWords * static_cast(slice)); + } + if (allSlicesRead && + ReadbackImpl::StorePackedWordsToClient(reinterpret_cast(words.data()), size.x(), + size.y(), sliceCount, type, pixels, + applyPackImageParams)) { + MGLOG_D("GetTexImage: finished %d slice(s) via the bit-exact GL_R32UI word readback", sliceCount); + return; + } + } + // The last resort for the one format the attachment route can never answer for: the + // shadow is only right while nothing but a glTexImage has written the level, which is + // why CopyImageSubData mirrors itself into it where it can. const Bool verbatimPackedShadowRead = MG_Util::PixelStoreProcessor::HasRedundantPackedEncoding(textureObject->GetFormat()) && - MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer( - textureObject->GetFormat(), MG_Util::ConvertGLEnumToTextureInputFormat(format), - MG_Util::ConvertGLEnumToTexturePixelDataType(type)); + rawPackedWordRead; if (verbatimPackedShadowRead && GetTexImageViaShadowConversion(textureMipmapObject, MG_Util::ConvertGLEnumToTextureUploadTarget(target), level, size.x(), @@ -9262,6 +9440,7 @@ namespace MobileGL::MG_Backend::DirectGLES { XfbImpl::OnBackendContextDestroyed(); MultiDrawImpl::OnBackendContextDestroyed(); ScratchFBOImpl::OnBackendContextDestroyed(); + ReleasePackedWordScratchTexture(); FramebufferImpl::InvalidateFramebufferBindingCache(); VertexArrayImpl::InvalidateVAOBindingCache(); PixelStoreImpl::InvalidatePackStateCache(); diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index e5412602..4616a9f8 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -91,6 +91,7 @@ add_executable(MobileGLIntegrationTest Scenarios/VertexArrayEnableDisableScenario.cpp Scenarios/CopyImageLevelRangeScenario.cpp Scenarios/CopyImageLayeredScenario.cpp + Scenarios/PackedWordReadbackScenario.cpp Scenarios/LayeredAttachmentBarrierScenario.cpp Scenarios/LayeredTextureReadbackScenario.cpp Scenarios/AtomicCounterScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PackedWordReadbackScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PackedWordReadbackScenario.cpp new file mode 100644 index 00000000..0527a15e --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PackedWordReadbackScenario.cpp @@ -0,0 +1,220 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PackedWordReadbackScenario.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 +// +// glGetTexImage of a 32-bit packed format read with its OWN client type owes the application the +// words the image HOLDS, and KHR-GL43.copy_image compares exactly those words. Two routes used to +// answer, and both are wrong for a level glCopyImageSubData wrote: +// +// * the colour-attachment route reads GL_RGBA/GL_FLOAT and re-encodes, which canonicalizes an +// RGB9_E5 shared exponent and collapses an R11F_G11F_B10F NaN payload to 1; +// * the CPU shadow only holds what was UPLOADED, and the mirror that replays a copy into it +// declines - silently - for a renderbuffer source, which has no shadow to mirror from. +// +// Both are pinned here with words the CTS itself uses, because both failures are invisible to a +// value comparison: every assertion below is on BITS that decode to the very value the wrong +// answer also decodes to. +// +// The fix is a raw-word route (DirectGLES::ReadPackedLevelWordsViaScratch: copy the level into a +// scratch GL_R32UI image, read that back as unsigned integers), and DirectVulkan reaches the same +// place through PackReadbackToClientOrPbo's raw-word branch over the staging bytes - so these +// scenarios are backend-agnostic on purpose. + +#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 GLsizei kExtent = 4; + + // The non-canonical RGB9_E5 word KHR-GL43.copy_image writes: R=0, G=0, B mantissa 63, + // shared exponent 31, i.e. the value 8064, which the spec's own encoder would emit as + // 0xe7e00000 instead. Anything that decodes and re-encodes hands back the canonical word. + // + // Reinterpreted in the destination of an RGB9_E5 -> R11F_G11F_B10F copy it is R=0, + // G=1920, B=995 - and B's 5-bit exponent is all ones with a nonzero mantissa, i.e. a NaN + // whose payload 3 does not survive a float32 round trip (it comes back as the canonical + // payload 1, B=993, word 0xf87c0000). The two defects therefore land on the same word. + constexpr GLuint kRgb9E5Word = 0xf8fc0000u; + + // The R11F_G11F_B10F word the same test pairs with it: R=0, G=0, B = exponent 12, + // mantissa 0 = 0.125. As an RGB9_E5 word it is all-zero channels with a shared exponent of + // 12, which the canonical encoder would write as 0x00000000 - so a decode/re-encode of THIS + // one loses every bit that distinguishes it. + constexpr GLuint kR11fG11fB10fWord = 0x60000000u; + + class PackedWordReadbackScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + DrainErrors(); + } + + void TearDown() override { + if (!Ready()) return; + DeleteObjects(); + DrainErrors(); + ScenarioTest::TearDown(); + } + + static void DrainErrors() { + for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) { + } + } + + void DeleteObjects() { + if (m_src != 0) glDeleteTextures(1, &m_src); + if (m_dst != 0) glDeleteTextures(1, &m_dst); + if (m_rbo != 0) glDeleteRenderbuffers(1, &m_rbo); + m_src = 0; + m_dst = 0; + m_rbo = 0; + } + + // A complete single-level texture whose every texel holds `word`, uploaded through the + // packed client type so the stored bits are the client's bits and nothing has had a + // chance to re-encode them. + GLuint MakePackedTexture(GLenum internalFormat, GLenum type, GLuint word) { + const std::vector words(static_cast(kExtent) * kExtent, word); + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, static_cast(internalFormat), kExtent, kExtent, 0, GL_RGB, type, + words.data()); + // What Utils::makeTextureComplete does in the conformance cases, and what + // glCopyImageSubData requires of both endpoints. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 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; + } + + // Every texel of level 0, as raw client words. + std::vector ReadPackedWords(GLuint texture, GLenum type) { + std::vector words(static_cast(kExtent) * kExtent, 0xDEADBEEFu); + glBindTexture(GL_TEXTURE_2D, texture); + glGetTexImage(GL_TEXTURE_2D, 0, GL_RGB, type, words.data()); + glBindTexture(GL_TEXTURE_2D, 0); + return words; + } + + // The copy under test. Returns the error it raised so a driver that cannot perform the + // move at all can skip rather than fail: the point of these cases is which BITS come + // back, and there are none to compare if the copy never happened. + GLenum CopyWholeImage(GLuint srcName, GLenum srcTarget, GLuint dstName, GLenum dstTarget) { + DrainErrors(); + glCopyImageSubData(srcName, srcTarget, 0, 0, 0, 0, dstName, dstTarget, 0, 0, 0, 0, kExtent, kExtent, + 1); + const GLenum error = glGetError(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "the copy recorded more than one error"; + return error; + } + + static void ExpectEveryTexel(const std::vector& words, GLuint expected, const char* what) { + for (std::size_t i = 0; i < words.size(); ++i) { + ASSERT_EQ(words[i], expected) + << what << ": texel " << i << " read 0x" << std::hex << words[i] << ", expected 0x" + << expected; + } + } + + GLuint m_src = 0; + GLuint m_dst = 0; + GLuint m_rbo = 0; + }; + + // The control that has to hold before either regression means anything: a packed word + // uploaded and read straight back must be the SAME word, not merely the same colour. + TEST_F(PackedWordReadbackScenario, AnUploadedPackedWordReadsBackVerbatim) { + if (!Ready()) GTEST_SKIP(); + + m_src = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, kRgb9E5Word); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "RGB9_E5 upload"; + ExpectEveryTexel(ReadPackedWords(m_src, GL_UNSIGNED_INT_5_9_9_9_REV), kRgb9E5Word, "RGB9_E5 round trip"); + + m_dst = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, kR11fG11fB10fWord); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "R11F_G11F_B10F upload"; + ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_10F_11F_11F_REV), kR11fG11fB10fWord, + "R11F_G11F_B10F round trip"); + } + + // KHR-GL43.copy_image.functional rgb9_e5 -> r11f_g11f_b10f, all nine target combinations of + // which failed on both GPUs. glCopyImageSubData is a raw block move, so the destination + // physically holds the source's word - but the readback decoded it to float and re-encoded, + // and the destination's blue field is a NaN whose payload float32 does not carry. Every + // texel came back 0xf87c0000 (payload 1) instead of 0xf8fc0000 (payload 3): the same + // "colour", two bits apart. + TEST_F(PackedWordReadbackScenario, ACopiedRgb9E5WordSurvivesInAnR11fG11fB10fDestination) { + if (!Ready()) GTEST_SKIP(); + + m_src = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, kRgb9E5Word); + m_dst = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, 0u); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup"; + + const GLenum copyError = CopyWholeImage(m_src, GL_TEXTURE_2D, m_dst, GL_TEXTURE_2D); + if (copyError != static_cast(GL_NO_ERROR)) { + GTEST_SKIP() << "this driver declined the RGB9_E5 -> R11F_G11F_B10F copy (" << copyError << ")"; + } + + ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_10F_11F_11F_REV), kRgb9E5Word, + "copied word in the R11F_G11F_B10F destination"); + // ...and the source is still the source. This is verify()'s FIRST check in the + // conformance case, and the half that a canonicalizing readback fails on its own. + ExpectEveryTexel(ReadPackedWords(m_src, GL_UNSIGNED_INT_5_9_9_9_REV), kRgb9E5Word, + "the RGB9_E5 source after the copy"); + } + + // KHR-GL43.copy_image.functional *->rgb9_e5 with a GL_RENDERBUFFER source: exactly the three + // renderbuffer combinations of each such family failed, and no texture one did. The + // destination's CPU shadow is what the readback answered from, the mirror that replays a + // copy into it declines when an endpoint is a renderbuffer (there is no shadow to mirror + // FROM), and the decline is silent - so glGetTexImage handed back the destination's + // pre-copy contents. The word chosen here makes that unmissable: it decodes to the same + // all-zero channels the canonical encoder would write as 0x00000000. + TEST_F(PackedWordReadbackScenario, ACopyThroughARenderbufferReachesAnRgb9E5Destination) { + if (!Ready()) GTEST_SKIP(); + + m_src = MakePackedTexture(GL_R11F_G11F_B10F, GL_UNSIGNED_INT_10F_11F_11F_REV, kR11fG11fB10fWord); + m_dst = MakePackedTexture(GL_RGB9_E5, GL_UNSIGNED_INT_5_9_9_9_REV, 0xFFFFFFFFu); + glGenRenderbuffers(1, &m_rbo); + glBindRenderbuffer(GL_RENDERBUFFER, m_rbo); + glRenderbufferStorage(GL_RENDERBUFFER, GL_R11F_G11F_B10F, kExtent, kExtent); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "renderbuffer setup"; + + // The conformance case's own shape: texture -> renderbuffer -> texture. + const GLenum toRenderbuffer = CopyWholeImage(m_src, GL_TEXTURE_2D, m_rbo, GL_RENDERBUFFER); + if (toRenderbuffer != static_cast(GL_NO_ERROR)) { + GTEST_SKIP() << "this driver declined a renderbuffer copy destination (" << toRenderbuffer << ")"; + } + const GLenum fromRenderbuffer = CopyWholeImage(m_rbo, GL_RENDERBUFFER, m_dst, GL_TEXTURE_2D); + if (fromRenderbuffer != static_cast(GL_NO_ERROR)) { + GTEST_SKIP() << "this driver declined a renderbuffer copy source (" << fromRenderbuffer << ")"; + } + + ExpectEveryTexel(ReadPackedWords(m_dst, GL_UNSIGNED_INT_5_9_9_9_REV), kR11fG11fB10fWord, + "copied word in the RGB9_E5 destination"); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Util/Texture/PixelStoreProcessor.h b/MobileGL/MG_Util/Texture/PixelStoreProcessor.h index c589f1ff..fc778245 100644 --- a/MobileGL/MG_Util/Texture/PixelStoreProcessor.h +++ b/MobileGL/MG_Util/Texture/PixelStoreProcessor.h @@ -47,12 +47,18 @@ namespace MobileGL::MG_Util::PixelStoreProcessor { // True when a packed internal format has REDUNDANT encodings, so decoding a texel and // re-encoding it keeps the VALUE but not the BITS. Only RGB9_E5 does: its shared exponent can // be lowered with the mantissas shifted up to match, and the spec's encoder always emits the - // canonical form. RGB10_A2, RGB10_A2UI and R11F_G11F_B10F round-trip through float32 - // bit-exactly, so a GPU readback can answer for them. + // canonical form, so no readback that goes through a decode cycle can return the stored words. // - // This is what decides whether the CPU shadow has to stay authoritative for a format: a - // readback of an RGB9_E5 level through a colour attachment cannot return the stored words, no - // matter how well behaved the driver is. + // Read this as "a FINITE value re-encodes to different bits", and nothing wider. This comment + // used to assert that RGB10_A2, RGB10_A2UI and R11F_G11F_B10F "round-trip through float32 + // bit-exactly, so a GPU readback can answer for them", and that is false for + // R11F_G11F_B10F: a field whose 5-bit exponent is all ones is an Inf or a NaN, and a NaN's + // payload does not survive the trip (EncodeFloatToUnsignedSmallFloat re-encodes every NaN as + // the canonical payload 1). glCopyImageSubData from an RGB9_E5 source produces exactly such a + // word in the blue field on every texel, because the source's shared-exponent field is all + // ones. The bit-exact answer for all four formats is the raw-word route, + // DirectGLES::ReadPackedLevelWordsViaScratch; this predicate only picks which of the older + // fallbacks to prefer when that route is unavailable. Bool HasRedundantPackedEncoding(TextureInternalFormat internalFormat); // Decodes the canonical shadow-mip storage of `internalFormat` into wide RGBA texels for CPU