From 568090f09fdd01525f5c2514a657f7e22a8324bf Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 03:06:45 -0400 Subject: [PATCH 1/4] [Fix] (Espryt): the image-bindable re-mint reads every level back from the driver texture before replaying it, so GPU-written levels survive the first glBindImageTexture - iris-derivative SSIM 0.996196552 -> 0.996376198 --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 2 + MobileGL/MG_Backend/DirectGLES/DirectGLES.h | 7 + MobileGL/MG_Backend/DirectGLES/Managers.cpp | 121 ++++ .../DirectGLES/WireTextureReadback.inc | 282 ++++++++++ MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Scenarios/ImageBindableRemintScenario.cpp | 516 ++++++++++++++++++ 6 files changed, 929 insertions(+) create mode 100644 MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 726cbf8d..dbc42c72 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -9014,6 +9014,8 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } +#include "WireTextureReadback.inc" + 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; diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index 94947965..1fee4ce3 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -23,6 +23,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // Content uploads use scratch bindings, so draws and dispatches call this after // texture synchronization. void BindCurrentTextures(); + // Driver-side color/depth level readback. Returns tightly packed, owned + // bytes in the requested pair; never consults a frontend texture or PACK/PBO. + // The caller supplies identity and extent, and whether the SOURCE allocation + // already uses an image carrier. + Bool ReadTextureLevelTight(GLuint texture, TextureTarget target, TextureUploadTarget uploadTarget, + TextureInternalFormat logicalFormat, GLint level, const IntVec3& logicalExtent, + Bool sourceUsesImageCarrier, GLenum format, GLenum type, Vector& bytes); void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil); void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value); void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index e0012f3c..bd1f76f2 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -2786,13 +2786,119 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendTextureId; } + // Replaces one level's shadow bytes with what the driver texture holds for it, then copies + // back the client writes the driver has not been sent yet (the level's pending rects, the + // same boxes a plain sync would upload over the driver's copy). Returns false, leaving the + // shadow untouched, when the driver's copy cannot stand in for the shadow: the client owns + // every byte of the level, the level is stored compressed, the driver has no image at this + // level and extent (defined after the last sync), or the readback cannot be served (a + // multisample image, a format the driver will not attach to a framebuffer). + static Bool AdoptDriverLevelIntoShadow(GLuint backendTextureId, BackendTextureObject& backendTexture, + const MG_State::GLState::ITextureObject& stateTextureObject, + MG_State::GLState::TextureObjectMipmap& mipmapObject, + TextureUploadTarget uploadTarget, Uint level) { + const IntVec3 texelSize = mipmapObject.GetMipmapTexelSize(uploadTarget, level); + const IntVec3 extent{texelSize.x(), texelSize.y(), std::max(texelSize.z(), 1)}; + const Bool dirty = mipmapObject.IsStorageDirty(uploadTarget, level); + const MG_State::GLState::MipmapDirtyRegion dirtyRegion = + dirty ? mipmapObject.GetStorageDirtyRegion(uploadTarget, level) + : MG_State::GLState::MipmapDirtyRegion{}; + if (dirty && dirtyRegion.CoversWholeLevel(extent)) return false; + if (mipmapObject.GetMipmapCompressedFormat(uploadTarget, level) != GL_NONE) return false; + if (!g_GLESFuncs.glGetTexLevelParameteriv) return false; + + const TextureTarget target = stateTextureObject.GetTarget(); + const GLenum nativeTarget = ConvertTextureTargetToBackendGLEnum(target); + const GLenum faceTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); + const IntVec3 backendExtent = GetBackendUploadSize(target, texelSize); + GLint driverWidth = 0, driverHeight = 0, driverDepth = 0; + backendTexture.Bind(nativeTarget); + g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast(level), GL_TEXTURE_WIDTH, &driverWidth); + g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast(level), GL_TEXTURE_HEIGHT, + &driverHeight); + g_GLESFuncs.glGetTexLevelParameteriv(faceTarget, static_cast(level), GL_TEXTURE_DEPTH, &driverDepth); + if (driverWidth != backendExtent.x() || driverHeight != backendExtent.y() || + std::max(driverDepth, 1) != std::max(backendExtent.z(), 1)) { + return false; + } + + const TextureInternalFormat format = stateTextureObject.GetFormat(); + GLenum shadowFormat = GL_NONE, shadowType = GL_NONE; + MG_Util::TextureFormatProcessor::NormalizePixelFormat(MG_Util::ConvertTextureInternalFormatToGLEnum(format), + {}, nullptr, &shadowFormat, &shadowType); + Vector snapshot; + if (!ReadTextureLevelTight(backendTextureId, target, uploadTarget, format, static_cast(level), + texelSize, /*sourceUsesImageCarrier=*/false, shadowFormat, shadowType, + snapshot)) { + return false; + } + const SizeT shadowBytes = mipmapObject.GetMipmapByteSize(uploadTarget, level); + const SizeT texelCount = + static_cast(extent.x()) * static_cast(extent.y()) * static_cast(extent.z()); + auto* shadow = static_cast(mipmapObject.MapMipmapData(uploadTarget, level)); + if (shadow == nullptr || snapshot.size() != shadowBytes || texelCount == 0 || shadowBytes % texelCount) { + return false; + } + + if (dirty) { + // The rects index the SHADOW's texel grid (a 1D array's layers are its rows), which + // is also the snapshot's byte order. + const SizeT bpp = shadowBytes / texelCount; + const SizeT rowBytes = static_cast(extent.x()) * bpp; + const SizeT sliceBytes = static_cast(extent.y()) * rowBytes; + constexpr SizeT kMaxPending = MG_State::GLState::MipmapStorage::kMaxDirtyRects; + MG_State::GLState::MipmapDirtyRegion pending[kMaxPending]; + SizeT pendingCount = mipmapObject.GetStorageDirtyRects(uploadTarget, level, pending, kMaxPending); + if (pendingCount == 0) { + pending[0] = dirtyRegion; + pendingCount = 1; + } + for (SizeT i = 0; i < pendingCount; ++i) { + const auto& box = pending[i]; + if (box.Empty()) continue; + const SizeT spanBytes = static_cast(box.hi.x() - box.lo.x()) * bpp; + for (Int z = box.lo.z(); z < box.hi.z(); ++z) { + for (Int y = box.lo.y(); y < box.hi.y(); ++y) { + const SizeT offset = static_cast(z) * sliceBytes + static_cast(y) * rowBytes + + static_cast(box.lo.x()) * bpp; + Memcpy(snapshot.data() + offset, shadow + offset, spanBytes); + } + } + } + } + Memcpy(shadow, snapshot.data(), shadowBytes); + return true; + } + void BackendTextureObject::RequireImageBindableStorage( const SharedPtr& stateTextureObject) { if (m_imageBindableStorageRequired) { return; } + // Read before the two flags below schedule the re-mint: whether the driver texture + // already holds this object's levels, and so may hold texels the shadow never saw. + const Bool hadBackendStorage = m_isInitialized && m_backendTextureId != 0 && + m_contextGeneration == g_backendContextGeneration; m_imageBindableStorageRequired = true; + // ES only needs the storage to be immutable, so an immutable allocation that needs no + // channel widening is already the one a re-mint would produce: keep it, and with it + // every texel the GPU wrote. Pending uploads and real redefinitions still go through + // the normal shape and dirty checks of the next sync. + if (hadBackendStorage && m_backendStorageImmutable && !stateTextureObject->IsTextureView() && + !IsMultisampleTextureTarget(stateTextureObject->GetTarget()) && + !GetImageBindableStorageWidening(stateTextureObject->GetFormat())) { + m_forceTextureParamsResync = true; + return; + } m_isInitialized = false; + // A view owns no levels. Its sync hands the requirement to the storage texture + // (SyncTextureViewToBackend), whose own transition below re-mints and replays them; a + // re-dirty issued here, through the view, would reach that texture first and claim + // every level for the shadow before the driver's copy could be read back. + if (stateTextureObject->IsTextureView()) { + m_forceTextureParamsResync = true; + return; + } // Every level this object has ALREADY uploaded has to be replayed, because the // regeneration this transition schedules re-mints the storage in the image carrier and // only uploads levels the shadow still calls dirty - which, for a texture that was @@ -2803,6 +2909,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // the image loads are wrong. Reached whenever anything syncs the texture first - a // glGetTexImage, a draw that samples it, an FBO attach - which is why it survived so // long: the scenario that binds the image immediately after uploading never sees it. + // + // And the shadow is only the CLIENT's copy. Whatever the GPU wrote into a level - a + // render or clear into it, a copy, a glGenerateMipmap - lives in the driver texture + // alone, so replaying the shadow as it stands put the pre-render bytes (or the zeroes + // of a null-data definition) into the new storage. The driver's levels are therefore + // read back into the shadow first, with the client writes still pending merged over + // them, while the old storage still exists; a level the driver cannot hand back keeps + // the shadow, which is all this used to replay. if (auto* mipmapObject = MG_State::GLState::AsMipmapTexture(stateTextureObject.get())) { const auto levelCount = mipmapObject->GetMipmapLevelCount(); for (const auto& uploadTarget : stateTextureObject->GetUploadTargets()) { @@ -2810,6 +2924,13 @@ namespace MobileGL::MG_Backend::DirectGLES { const auto levelTexelSize = mipmapObject->GetMipmapTexelSize(uploadTarget, level); if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0) continue; if (mipmapObject->GetMipmapByteSize(uploadTarget, level) == 0) continue; + if (hadBackendStorage && + !AdoptDriverLevelIntoShadow(m_backendTextureId, *this, *stateTextureObject, + *mipmapObject, uploadTarget, level)) { + MGLOG_D("Texture %u level %u: re-minting from the shadow, the driver's copy is not " + "readable here", + stateTextureObject->GetExternalIndex(), level); + } mipmapObject->MarkStorageDirty(uploadTarget, level, true); } } diff --git a/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc b/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc new file mode 100644 index 00000000..5f7b0b6f --- /dev/null +++ b/MobileGL/MG_Backend/DirectGLES/WireTextureReadback.inc @@ -0,0 +1,282 @@ +// Included in DirectGLES.cpp after the native packed-word readback helper. +// A storage promotion needs the driver's own texels as owned tight rows, not +// client PACK layout or a fallback into a frontend texture's potentially stale +// shadow. + class ScopedWireTextureReadLevel { + public: + ScopedWireTextureReadLevel(GLuint texture, GLenum target, GLint level) + : m_texture(texture), m_target(target) { + switch (target) { + case GL_TEXTURE_2D: m_binding = GL_TEXTURE_BINDING_2D; break; + case GL_TEXTURE_3D: m_binding = GL_TEXTURE_BINDING_3D; break; + case GL_TEXTURE_2D_ARRAY: m_binding = GL_TEXTURE_BINDING_2D_ARRAY; break; + case GL_TEXTURE_CUBE_MAP: m_binding = GL_TEXTURE_BINDING_CUBE_MAP; break; + case GL_TEXTURE_CUBE_MAP_ARRAY: m_binding = GL_TEXTURE_BINDING_CUBE_MAP_ARRAY; break; + default: return; + } + GLint previous = 0; + DrainESErrors(); + g_GLESFuncs.glGetIntegerv(m_binding, &previous); + g_GLESFuncs.glBindTexture(m_target, m_texture); + g_GLESFuncs.glGetTexParameteriv(m_target, GL_TEXTURE_BASE_LEVEL, &m_baseLevel); + g_GLESFuncs.glGetTexParameteriv(m_target, GL_TEXTURE_MAX_LEVEL, &m_maxLevel); + m_saved = g_GLESFuncs.glGetError() == GL_NO_ERROR; + if (m_saved) { + // ES framebuffer completeness can reject a defined level whose + // base level or intervening mip chain is missing/inconsistent. + // Read this existing native image as the sole base level. The + // logical GL parameters and every cached copy remain unchanged. + g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_BASE_LEVEL, level); + g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_MAX_LEVEL, level); + m_valid = g_GLESFuncs.glGetError() == GL_NO_ERROR; + } + g_GLESFuncs.glBindTexture(m_target, static_cast(previous)); + } + + ~ScopedWireTextureReadLevel() { + if (!m_saved) return; + // The packed-word path can use its own texture scratch binding. + // Save the binding at restoration time too; do not assume an active + // texture unit or make the backend's binding cache forget a bind. + GLint previous = 0; + g_GLESFuncs.glGetIntegerv(m_binding, &previous); + g_GLESFuncs.glBindTexture(m_target, m_texture); + g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_BASE_LEVEL, m_baseLevel); + g_GLESFuncs.glTexParameteri(m_target, GL_TEXTURE_MAX_LEVEL, m_maxLevel); + g_GLESFuncs.glBindTexture(m_target, static_cast(previous)); + } + + Bool Valid() const { return m_valid; } + + private: + GLuint m_texture; + GLenum m_target; + GLenum m_binding = GL_NONE; + GLint m_baseLevel = 0, m_maxLevel = 0; + Bool m_saved = false, m_valid = false; + }; + + static Bool ReadWireColorRowsTight(GLsizei width, GLsizei height, GLenum format, GLenum type, + Bool forceOpaqueAlpha, const NormalizedImageCarrierRead& carrier, + Vector& bytes) { + ReadbackChannelMapping mapping{}; + if (!GetReadbackChannelMapping(format, mapping)) return false; + const SizeT pixelBytes = GetReadbackDstPixelSize(mapping, type); + if (!pixelBytes || width <= 0 || height <= 0) return false; + const GLenum componentType = QueryReadAttachmentComponentType(); + const Bool integerAttachment = componentType == GL_INT || componentType == GL_UNSIGNED_INT; + if (carrier.Active() ? (mapping.isInteger || !integerAttachment) + : mapping.isInteger != integerAttachment) return false; + + GLint implFormat = 0, implType = 0; + g_GLESFuncs.glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_FORMAT, &implFormat); + g_GLESFuncs.glGetIntegerv(GL_IMPLEMENTATION_COLOR_READ_TYPE, &implType); + struct Candidate { GLenum format, type; }; + Vector candidates; + if (carrier.Active()) { + candidates.push_back({GL_RGBA_INTEGER, GL_UNSIGNED_INT}); + } else if (mapping.isInteger) { + if (GetWideReadChannelCount(implFormat) > 0 && IsIntegerReadFormat(implFormat) && + (implType == GL_INT || implType == GL_UNSIGNED_INT)) + candidates.push_back({static_cast(implFormat), static_cast(implType)}); + candidates.push_back({GL_RGBA_INTEGER, componentType == GL_INT ? GLenum(GL_INT) : GLenum(GL_UNSIGNED_INT)}); + } else { + if (GetWideReadChannelCount(implFormat) > 0 && !IsIntegerReadFormat(implFormat) && + (CanDecodeWideSourceType(implType) || + (implFormat == GL_RGBA && implType == GL_UNSIGNED_INT_2_10_10_10_REV))) + candidates.push_back({static_cast(implFormat), static_cast(implType)}); + if (componentType == GL_FLOAT) candidates.push_back({GL_RGBA, GL_FLOAT}); + if (componentType == GL_SIGNED_NORMALIZED) { + candidates.push_back({GL_RGBA, GL_SHORT}); + candidates.push_back({GL_RGBA, GL_BYTE}); + } else candidates.push_back({GL_RGBA, GL_UNSIGNED_BYTE}); + } + ScopedPixelPackBuffer packBuffer(0); + ScopedPackState packState(PixelStoreImpl::PackState{1, 0, 0, 0}); + const SizeT pixels = static_cast(width) * static_cast(height); + Vector wide; + GLenum wideType = GL_NONE; + Int channels = 0; + DrainESErrors(); + for (const auto candidate : candidates) { + channels = GetWideReadChannelCount(candidate.format); + wide.resize(pixels * static_cast(channels) * GetReadbackComponentSize(candidate.type)); + g_GLESFuncs.glReadPixels(0, 0, width, height, candidate.format, candidate.type, wide.data()); + if (g_GLESFuncs.glGetError() == GL_NO_ERROR) { wideType = candidate.type; break; } + } + if (wideType == GL_NONE) return false; + + if (carrier.Active()) { + Vector decoded(pixels * 4 * sizeof(Float)); + auto* dst = reinterpret_cast(decoded.data()); + const auto* src = reinterpret_cast(wide.data()); + for (SizeT i = 0; i < pixels; ++i) { + for (SizeT c = 0; c < 4; ++c) { + const Float divisor = static_cast(carrier.ChannelMax[c]); + dst[i * 4 + c] = carrier.SignedNormalized + ? std::max(static_cast(static_cast(static_cast(src[i * 4 + c]))) / divisor, -1.0f) + : static_cast(src[i * 4 + c]) / divisor; + } + } + wide = Move(decoded); wideType = GL_FLOAT; channels = 4; + } else if (wideType == GL_UNSIGNED_INT_2_10_10_10_REV) { + Vector decoded(pixels * 4 * sizeof(Float)); + auto* dst = reinterpret_cast(decoded.data()); + for (SizeT i = 0; i < pixels; ++i) { + Uint32 word = 0; + Memcpy(&word, wide.data() + i * sizeof(word), sizeof(word)); + dst[i * 4] = static_cast(word & 1023u) / 1023.0f; + dst[i * 4 + 1] = static_cast((word >> 10) & 1023u) / 1023.0f; + dst[i * 4 + 2] = static_cast((word >> 20) & 1023u) / 1023.0f; + dst[i * 4 + 3] = static_cast((word >> 30) & 3u) / 3.0f; + } + wide = Move(decoded); wideType = GL_FLOAT; channels = 4; + } + if (channels < 4) ExpandNarrowWideRead(wide, pixels, channels, wideType); + if (forceOpaqueAlpha) ForceWideReadAlphaToOne(wide, pixels, wideType); + Vector converted(pixels * pixelBytes); + const SizeT sourceRowBytes = static_cast(width) * 4 * GetReadbackComponentSize(wideType); + for (GLsizei row = 0; row < height; ++row) + ReadbackImpl::ConvertWideReadbackRow(wide.data() + static_cast(row) * sourceRowBytes, + converted.data() + static_cast(row) * width * pixelBytes, width, wideType, mapping, type); + bytes = Move(converted); + return true; + } + + static Bool ReadWireDepthStencilLevelTight(GLuint texture, TextureTarget target, + TextureUploadTarget uploadTarget, GLint level, + const IntVec3& extent, GLenum format, GLenum type, + Vector& bytes) { + const SizeT pixelBytes = MG_Util::GetInputBytesPerPixel( + MG_Util::ConvertGLEnumToTextureInputFormat(format), MG_Util::ConvertGLEnumToTexturePixelDataType(type)); + if (!pixelBytes) return false; + const Bool needDepth = format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL; + const Bool needStencil = format == GL_STENCIL_INDEX || format == GL_DEPTH_STENCIL; + ScopedFramebufferBinding bindings(true, false); + GLuint fbo = 0; + g_GLESFuncs.glGenFramebuffers(1, &fbo); + struct DeleteFbo { GLuint id; ~DeleteFbo() { g_GLESFuncs.glDeleteFramebuffers(1, &id); } } cleanup{fbo}; + FramebufferImpl::BindFramebufferId(GL_READ_FRAMEBUFFER, fbo); + ScopedPixelPackBuffer packBuffer(0); + ScopedPackState packState(PixelStoreImpl::PackState{1, 0, 0, 0}); + const GLenum nativeTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(target); + const GLenum point = needDepth ? (needStencil ? GL_DEPTH_STENCIL_ATTACHMENT : GL_DEPTH_ATTACHMENT) + : GL_STENCIL_ATTACHMENT; + const SizeT count = static_cast(extent.x()) * extent.y(); + Vector result(count * extent.z() * pixelBytes); + if (g_GLESFuncs.glMemoryBarrier) g_GLESFuncs.glMemoryBarrier(GL_ALL_BARRIER_BITS); + g_GLESFuncs.glFinish(); + for (GLint z = 0; z < extent.z(); ++z) { + if (nativeTarget == GL_TEXTURE_3D || nativeTarget == GL_TEXTURE_2D_ARRAY || + nativeTarget == GL_TEXTURE_CUBE_MAP_ARRAY) + g_GLESFuncs.glFramebufferTextureLayer(GL_READ_FRAMEBUFFER, point, texture, level, z); + else + g_GLESFuncs.glFramebufferTexture2D(GL_READ_FRAMEBUFFER, point, + TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(uploadTarget), texture, level); + g_GLESFuncs.glReadBuffer(GL_NONE); + if (g_GLESFuncs.glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false; + Vector depth; + Vector stencil; + if (needDepth && !ReadDepthValuesNative(0, 0, extent.x(), extent.y(), depth)) return false; + if (needStencil && !ReadStencilBytesNative(0, 0, extent.x(), extent.y(), stencil)) return false; + auto* dst = result.data() + count * z * pixelBytes; + for (SizeT i = 0; i < count; ++i) { + if (format == GL_DEPTH_STENCIL) { + const Uint32 s = stencil[i]; + if (type == GL_UNSIGNED_INT_24_8) { + const Uint32 packed = (NormalizedDepthToUnsigned(depth[i], 16777215.0) << 8) | s; + Memcpy(dst + i * pixelBytes, &packed, sizeof(packed)); + } else if (type == GL_FLOAT_32_UNSIGNED_INT_24_8_REV) { + Memcpy(dst + i * pixelBytes, &depth[i], sizeof(Float)); + Memcpy(dst + i * pixelBytes + sizeof(Float), &s, sizeof(s)); + } else return false; + } else if (type == GL_FLOAT) { + const Float value = needDepth ? depth[i] : static_cast(stencil[i]); + Memcpy(dst + i * pixelBytes, &value, sizeof(value)); + } else if (type == GL_HALF_FLOAT) { + const Uint16 value = MG_Util::EncodeFloatToHalfBits( + needDepth ? depth[i] : static_cast(stencil[i])); + Memcpy(dst + i * pixelBytes, &value, sizeof(value)); + } else { + const Bool signedType = type == GL_BYTE || type == GL_SHORT || type == GL_INT; + const Double maximum = signedType + ? (pixelBytes == 1 ? 127.0 : pixelBytes == 2 ? 32767.0 : 2147483647.0) + : (pixelBytes == 1 ? 255.0 : pixelBytes == 2 ? 65535.0 : 4294967295.0); + const Uint32 value = needDepth ? NormalizedDepthToUnsigned(depth[i], maximum) : stencil[i]; + if (pixelBytes == 1) dst[i] = static_cast(value); + else if (pixelBytes == 2) { const Uint16 v = static_cast(value); Memcpy(dst + i * 2, &v, 2); } + else if (pixelBytes == 4) Memcpy(dst + i * 4, &value, 4); + else return false; + } + } + } + bytes = Move(result); + return true; + } + + Bool ReadTextureLevelTight(GLuint texture, TextureTarget target, TextureUploadTarget uploadTarget, + TextureInternalFormat logicalFormat, GLint level, const IntVec3& logicalExtent, + Bool sourceUsesImageCarrier, GLenum format, GLenum type, Vector& bytes) { + if (!texture || level < 0 || TextureImpl::IsMultisampleTextureTarget(target)) return false; + const IntVec3 extent = TextureImpl::GetBackendUploadSize(target, logicalExtent); + const GLenum nativeTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(target); + ScopedWireTextureReadLevel readLevel(texture, nativeTarget, level); + if (!readLevel.Valid()) return false; + if (format == GL_DEPTH_COMPONENT || format == GL_DEPTH_STENCIL || format == GL_STENCIL_INDEX) + return ReadWireDepthStencilLevelTight(texture, target, uploadTarget, level, extent, format, type, bytes); + ReadbackChannelMapping mapping{}; + if (!GetReadbackChannelMapping(format, mapping)) return false; + const SizeT pixelBytes = GetReadbackDstPixelSize(mapping, type); + if (!pixelBytes || extent.x() <= 0 || extent.y() <= 0 || extent.z() <= 0) return false; + const GLenum faceTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); + const auto widening = sourceUsesImageCarrier + ? TextureImpl::GetImageBindableStorageWidening(logicalFormat) : TextureImpl::ImageBindableStorageWidening{}; + const SizeT sliceBytes = static_cast(extent.x()) * extent.y() * pixelBytes; + Vector result(sliceBytes * static_cast(extent.z())); + if (g_GLESFuncs.glMemoryBarrier) g_GLESFuncs.glMemoryBarrier(GL_ALL_BARRIER_BITS); + g_GLESFuncs.glFinish(); + + // A raw packed word may have redundant encodings or NaN payloads. Read + // it through R32UI when the source really still uses that native layout. + const Bool rawPacked = !widening && MG_Util::PixelStoreProcessor::IsRawPackedPixelTransfer( + logicalFormat, MG_Util::ConvertGLEnumToTextureInputFormat(format), + MG_Util::ConvertGLEnumToTexturePixelDataType(type)); + if (rawPacked && pixelBytes == sizeof(Uint32)) { + const GLint face = uploadTarget >= TextureUploadTarget::CubeMapPositiveX && + uploadTarget <= TextureUploadTarget::CubeMapNegativeZ + ? static_cast(uploadTarget) - static_cast(TextureUploadTarget::CubeMapPositiveX) : 0; + for (GLint layer = 0; layer < extent.z(); ++layer) + if (!ReadPackedLevelWordsViaScratch(texture, nativeTarget, level, face + layer, extent.x(), extent.y(), + reinterpret_cast(result.data() + static_cast(layer) * sliceBytes))) return false; + bytes = Move(result); + return true; + } + + NormalizedImageCarrierRead carrier{}; + if (widening && widening.CarriesNormalizedCodes()) { + for (SizeT c = 0; c < 4; ++c) carrier.ChannelMax[c] = widening.ChannelMax[c]; + carrier.SignedNormalized = widening.SignedNormalized; + } + TempFBOBinder binder(true); + auto& framebuffer = binder.Framebuffer(); + for (GLint layer = 0; layer < extent.z(); ++layer) { + if (nativeTarget == GL_TEXTURE_3D || nativeTarget == GL_TEXTURE_2D_ARRAY || nativeTarget == GL_TEXTURE_CUBE_MAP_ARRAY) + ScratchFBOImpl::EnsureColorAttachmentLayer(framebuffer, GL_READ_FRAMEBUFFER, texture, level, layer); + else + ScratchFBOImpl::EnsureColorAttachment2D(framebuffer, GL_READ_FRAMEBUFFER, texture, faceTarget, level); + ScratchFBOImpl::EnsureReadBuffer(framebuffer, GL_COLOR_ATTACHMENT0); + const GLenum status = g_GLESFuncs.glCheckFramebufferStatus(GL_READ_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + MGLOG_E_ONCE("Espryt wire texture readback: framebuffer status=0x%x texture=%u level=%d target=0x%x", + status, texture, level, nativeTarget); + return false; + } + Vector slice; + if (!ReadWireColorRowsTight(extent.x(), extent.y(), format, type, + TextureImpl::BackendTextureFormatAddsAlpha(logicalFormat, target), carrier, slice) || + slice.size() != sliceBytes) return false; + Memcpy(result.data() + static_cast(layer) * sliceBytes, slice.data(), sliceBytes); + } + bytes = Move(result); + return true; + } diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 7dad1f1d..3c4d3498 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -88,6 +88,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ImageFormatQualifierScenario.cpp Scenarios/NonCoreImageFormatScenario.cpp Scenarios/ImageSizeAfterRespecScenario.cpp + Scenarios/ImageBindableRemintScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp Scenarios/IoBlockNameCollisionScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp new file mode 100644 index 00000000..e06587fa --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp @@ -0,0 +1,516 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.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 - THE FIRST glBindImageTexture OF A TEXTURE THE GPU HAS ALREADY WRITTEN. +// +// ES only allows immutable storage on an image unit, and a few formats need a wider carrier on +// top (TextureImpl::GetImageBindableStorageWidening), so Espryt re-mints a texture the first time +// it is image-bound after it already has driver storage (RequireImageBindableStorage). The new +// storage has to hold what the old one held. The re-mint used to fill it from the frontend's +// shadow - the CLIENT's copy of each level - and the shadow never sees a GPU write: a render or +// clear into the level, a glGenerateMipmap, a null-data definition only the GPU ever filled. Every +// such level came back as the bytes the application last uploaded, or zeroes. That is the +// ~26.9k-pixel iris-derivative difference: an RGBA16F render target, first image-bound after it +// was rendered into. +// +// Every case gives a texture its content by one means, lets the backend create its storage (a +// sampling draw, or the FBO attach of a GPU clear), writes it on the GPU where the case says so, +// and then binds it to an image unit for the first time: a compute imageLoad copies the level into +// a destination that was image-bound before it was ever synced (so the destination itself never +// re-mints), and the destination is read back through an FBO. The expectation is the GL answer. +// +// The controls are the other half: levels the GPU never wrote (including formats whose driver +// readback may be refused), levels the driver does not have yet because they were defined after +// the last sync, and a pending client write that has to land on top of GPU content. Magma never +// re-mints from a shadow (it copies image to image) and is the control backend. + +#include +#include +#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 int kEdge = 8; + + constexpr const char* kVS = R"(#version 430 core +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); +} +)"; + constexpr const char* kSampleFS = R"(#version 430 core +uniform sampler2D u_tex; +out vec4 o_color; +void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); } +)"; + + // Copies an image into an RGBA8 image with imageLoad; a signed-normalized source is mapped + // to [0, 1] so it survives the unsigned destination. + std::string CopyCS(const char* srcFormat, bool snorm) { + std::string s = "#version 430 core\nlayout(local_size_x = 8, local_size_y = 8) in;\n"; + s += std::string("layout(") + srcFormat + ", binding = 0) readonly uniform image2D u_src;\n"; + s += "layout(rgba8, binding = 1) writeonly uniform image2D u_dst;\n"; + s += "void main() { ivec2 p = ivec2(gl_GlobalInvocationID.xy); vec4 v = imageLoad(u_src, p);\n"; + if (snorm) s += " v = v * 0.5 + 0.5;\n"; + s += " imageStore(u_dst, p, v); }\n"; + return s; + } + + std::vector Pattern(int edge, GLubyte magic) { + std::vector t(static_cast(edge * edge) * 4u); + for (int y = 0; y < edge; ++y) + for (int x = 0; x < edge; ++x) { + const std::size_t at = static_cast((y * edge + x) * 4); + t[at + 0] = magic; + t[at + 1] = static_cast(16 * (x + 1)); + t[at + 2] = static_cast(16 * (y + 1)); + t[at + 3] = 0xFF; + } + return t; + } + + std::vector Solid(int edge, GLubyte r, GLubyte g, GLubyte b, GLubyte a) { + std::vector t(static_cast(edge * edge) * 4u); + for (std::size_t i = 0; i < t.size(); i += 4) { + t[i] = r; + t[i + 1] = g; + t[i + 2] = b; + t[i + 3] = a; + } + return t; + } + + const GLfloat kGreen[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + + class ImageBindableRemintScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + std::string error; + m_sampleProgram = CompileProgram(kVS, kSampleFS, &error); + ASSERT_NE(m_sampleProgram, 0u) << error; + m_scratch = MakeColorFbo(4, 4); + glGenVertexArrays(1, &m_vao); + FirstGLError(); + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + if (m_sampleProgram) glDeleteProgram(m_sampleProgram); + if (m_vao) glDeleteVertexArrays(1, &m_vao); + if (!m_textures.empty()) glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + if (m_fbo) glDeleteFramebuffers(1, &m_fbo); + BindDefaultFramebuffer(); + DestroyColorFbo(m_scratch); + glViewport(0, 0, Gl().Width(), Gl().Height()); + FirstGLError(); + } + + GLuint NewTexture() { + GLuint t = 0; + glGenTextures(1, &t); + m_textures.push_back(t); + return t; + } + + static void Nearest(GLenum target, GLuint tex, int maxLevel) { + glBindTexture(target, tex); + glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, maxLevel); + glBindTexture(target, 0); + } + + // A draw that samples the texture: the backend now holds storage for it. + void SyncBySampling(GLuint tex) { + BindFbo(m_scratch); + glUseProgram(m_sampleProgram); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, tex); + glUniform1i(glGetUniformLocation(m_sampleProgram, "u_tex"), 0); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glBindTexture(GL_TEXTURE_2D, 0); + glUseProgram(0); + BindDefaultFramebuffer(); + glFinish(); + } + + // A GPU write the frontend's shadow never sees. A layer >= 0 names one layer of an + // array texture. + void GpuClear(GLuint tex, int level, const GLfloat* color, int layer = -1) { + if (!m_fbo) glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + if (layer >= 0) { + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, level, layer); + } else { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, level); + } + EXPECT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "the clear target is incomplete"; + glClearBufferfv(GL_COLOR, 0, color); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0); + BindDefaultFramebuffer(); + } + + // The first image binding of (src, level[, layer]): an imageLoad copy into a fresh RGBA8 + // destination, read back through an FBO. + std::vector ImageCopy(GLuint src, int level, GLenum unitFormat, const char* glslFormat, + bool snorm = false, int layer = 0) { + const GLuint dst = NewTexture(); + glBindTexture(GL_TEXTURE_2D, dst); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge); + glBindTexture(GL_TEXTURE_2D, 0); + glBindImageTexture(1, dst, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8); + glBindImageTexture(0, src, level, GL_FALSE, layer, GL_READ_ONLY, unitFormat); + EXPECT_EQ(FirstGLError(), 0u) << "glBindImageTexture"; + const std::string cs = CopyCS(glslFormat, snorm); + const char* csp = cs.c_str(); + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &csp, nullptr); + glCompileShader(shader); + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + EXPECT_EQ(linked, GL_TRUE) << "the copy program did not link"; + glUseProgram(program); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + glUseProgram(0); + glDeleteProgram(program); + glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + if (!m_fbo) glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, dst, 0); + std::vector out(static_cast(kEdge * kEdge) * 4u, 0xA5); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, out.data()); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0); + BindDefaultFramebuffer(); + EXPECT_EQ(FirstGLError(), 0u) << "copy/readback"; + return out; + } + + // The edge x edge corner of `got` against an RGBA8 expectation, the first `channels` + // channels only, within 2 codes (a normalized value's round trip). + static void ExpectImage(const std::vector& got, int edge, const std::vector& want, + int channels = 4) { + int bad = 0; + std::ostringstream first; + for (int y = 0; y < edge; ++y) + for (int x = 0; x < edge; ++x) { + const std::size_t g = static_cast((y * kEdge + x) * 4); + const std::size_t w = static_cast((y * edge + x) * 4); + bool texelBad = false; + for (int c = 0; c < channels; ++c) + texelBad |= std::abs(int(got[g + c]) - int(want[w + c])) > 2; + if (texelBad && bad++ == 0) { + first << "(" << x << "," << y << ") got " << int(got[g]) << "," << int(got[g + 1]) << "," + << int(got[g + 2]) << "," << int(got[g + 3]) << " want " << int(want[w]) << "," + << int(want[w + 1]) << "," << int(want[w + 2]) << "," << int(want[w + 3]); + } + } + EXPECT_EQ(bad, 0) << bad << " of " << edge * edge << " texels differ; first " << first.str(); + } + + GLuint m_sampleProgram = 0, m_vao = 0, m_fbo = 0; + ColorFbo m_scratch{}; + std::vector m_textures; + }; + + // Control: the shadow and the driver agree. + TEST_F(ImageBindableRemintScenario, UploadedLevelKeepsItsUpload) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, p); + } + + TEST_F(ImageBindableRemintScenario, GpuClearOverAnUploadSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255)); + } + + // Immutable in a core image format: the storage is already what an image unit needs. + TEST_F(ImageBindableRemintScenario, GpuClearOverAnImmutableUploadSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255)); + } + + // A null-data definition only the GPU ever filled: the shadow holds zeroes. + TEST_F(ImageBindableRemintScenario, GpuClearOfANullDefinitionSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255)); + } + + // A client write still pending when the bind comes lands on top of the GPU's content, + // and only inside its own box. + TEST_F(ImageBindableRemintScenario, PendingSubImageLandsOnTheGpuClear) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + const auto blue = Solid(2, 0, 0, 255, 255); + glBindTexture(GL_TEXTURE_2D, t); + glTexSubImage2D(GL_TEXTURE_2D, 0, 2, 2, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, blue.data()); + glBindTexture(GL_TEXTURE_2D, 0); + auto want = Solid(kEdge, 0, 255, 0, 255); + for (int y = 2; y < 4; ++y) + for (int x = 2; x < 4; ++x) { + const std::size_t at = static_cast((y * kEdge + x) * 4); + want[at] = 0; + want[at + 1] = 0; + want[at + 2] = 255; + want[at + 3] = 255; + } + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, want); + } + + TEST_F(ImageBindableRemintScenario, GeneratedMipLevelSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto s = Solid(kEdge, 200, 100, 50, 255); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, s.data()); + glGenerateMipmap(GL_TEXTURE_2D); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glBindTexture(GL_TEXTURE_2D, 0); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), kEdge / 2, Solid(kEdge / 2, 200, 100, 50, 255)); + } + + TEST_F(ImageBindableRemintScenario, GpuClearOfAnUploadedMipLevelSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto s0 = Solid(kEdge, 200, 100, 50, 255); + const auto s1 = Solid(kEdge / 2, 10, 20, 30, 255); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, s0.data()); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, s1.data()); + Nearest(GL_TEXTURE_2D, t, 1); + SyncBySampling(t); + GpuClear(t, 1, kGreen); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), kEdge / 2, Solid(kEdge / 2, 0, 255, 0, 255)); + } + + // Control: a level defined (null data) after the last sync is not on the driver yet, so + // there is nothing to read back for it; the bound level 0 must still be the upload. + TEST_F(ImageBindableRemintScenario, LevelDefinedAfterTheSyncDoesNotDisturbTheOthers) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, p); + } + + // Control: the same late level, partly uploaded - the shadow is the only copy of it. + TEST_F(ImageBindableRemintScenario, LevelDefinedAndUploadedAfterTheSyncKeepsTheUpload) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge / 2, kEdge / 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + const auto blue = Solid(2, 0, 0, 255, 255); + glTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, 2, 2, GL_RGBA, GL_UNSIGNED_BYTE, blue.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1); + glBindTexture(GL_TEXTURE_2D, 0); + ExpectImage(ImageCopy(t, 1, GL_RGBA8, "rgba8"), 2, blue); + } + + // GL_RG8 is not an ESSL image format, so the re-mint also WIDENS the storage (to RGBA8) + // and cannot keep the old allocation even though it is immutable. + TEST_F(ImageBindableRemintScenario, GpuClearSurvivesAWideningRemint) { + if (!Ready()) return; + const GLuint t = NewTexture(); + std::vector rg(static_cast(kEdge * kEdge) * 2u, 0x33); + glBindTexture(GL_TEXTURE_2D, t); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RG8, kEdge, kEdge); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RG, GL_UNSIGNED_BYTE, rg.data()); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + const GLfloat c[4] = {0.5f, 0.25f, 0.0f, 1.0f}; + GpuClear(t, 0, c); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RG8, "rg8"), kEdge, Solid(kEdge, 128, 64, 0, 255), 2); + } + + // Control: widened, uploaded only. + TEST_F(ImageBindableRemintScenario, UploadSurvivesAWideningRemint) { + if (!Ready()) return; + const GLuint t = NewTexture(); + std::vector rg(static_cast(kEdge * kEdge) * 2u); + for (std::size_t i = 0; i < rg.size(); i += 2) { + rg[i] = 0x33; + rg[i + 1] = 0x99; + } + glBindTexture(GL_TEXTURE_2D, t); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RG8, kEdge, kEdge); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RG, GL_UNSIGNED_BYTE, rg.data()); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RG8, "rg8"), kEdge, Solid(kEdge, 0x33, 0x99, 0, 255), 2); + } + + // Control: a signed-normalized level (not colour-renderable on every ES driver, so its + // readback may be refused and the shadow used instead), uploaded only. + TEST_F(ImageBindableRemintScenario, SnormUploadSurvives) { + if (!Ready()) return; + const GLuint t = NewTexture(); + std::vector s(static_cast(kEdge * kEdge) * 4u); + for (std::size_t i = 0; i < s.size(); i += 4) { + s[i] = 127; + s[i + 1] = 0; + s[i + 2] = -127; + s[i + 3] = 127; + } + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8_SNORM, kEdge, kEdge, 0, GL_RGBA, GL_BYTE, s.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA8_SNORM, "rgba8_snorm", true), kEdge, Solid(kEdge, 255, 128, 0, 255), + 3); + } + + // Control: RGBA16_SNORM is desktop-only as an image format, so this re-mint widens too. + TEST_F(ImageBindableRemintScenario, Snorm16UploadSurvivesAWideningRemint) { + if (!Ready()) return; + const GLuint t = NewTexture(); + std::vector s(static_cast(kEdge * kEdge) * 4u); + for (std::size_t i = 0; i < s.size(); i += 4) { + s[i] = 32767; + s[i + 1] = 0; + s[i + 2] = -32767; + s[i + 3] = 32767; + } + glBindTexture(GL_TEXTURE_2D, t); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA16_SNORM, kEdge, kEdge); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_SHORT, s.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + ExpectImage(ImageCopy(t, 0, GL_RGBA16_SNORM, "rgba16_snorm", true), kEdge, + Solid(kEdge, 255, 128, 0, 255), 3); + } + + // An array texture is read back one layer at a time: the GPU-cleared layer and the + // uploaded one both have to come through. The clear's FBO attach is what gives the + // texture its driver storage here. + TEST_F(ImageBindableRemintScenario, GpuClearOfOneArrayLayerSurvivesBesideTheUploadedLayer) { + if (!Ready()) return; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + std::vector layers(p); + layers.insert(layers.end(), p.begin(), p.end()); + glBindTexture(GL_TEXTURE_2D_ARRAY, t); + glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, kEdge, kEdge, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, + layers.data()); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + Nearest(GL_TEXTURE_2D_ARRAY, t, 0); + GpuClear(t, 0, kGreen, /*layer=*/1); + glFinish(); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8", false, /*layer=*/1), kEdge, Solid(kEdge, 0, 255, 0, 255)); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8", false, /*layer=*/0), kEdge, p); + } + + // A view owns no storage, so its first image binding is the VIEWED texture's transition, + // and the GPU's texels there have to come through it. Needs glTextureView, which Espryt + // only has where the ES driver does (not on llvmpipe). + TEST_F(ImageBindableRemintScenario, GpuClearSurvivesTheFirstImageBindingOfAView) { + if (!Ready()) return; + const GLuint owner = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, owner); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kEdge, kEdge); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kEdge, kEdge, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, owner, 0); + SyncBySampling(owner); + GpuClear(owner, 0, kGreen); + SyncBySampling(owner); + const GLuint view = NewTexture(); + FirstGLError(); + glTextureView(view, GL_TEXTURE_2D, owner, GL_RGBA8, 0, 1, 0, 1); + if (const GLenum error = FirstGLError()) { + GTEST_SKIP() << "glTextureView is unavailable on backend " << Gl().BackendName() << " (" + << GLErrorName(error) << ")"; + } + Nearest(GL_TEXTURE_2D, view, 0); + SyncBySampling(view); + ExpectImage(ImageCopy(view, 0, GL_RGBA8, "rgba8"), kEdge, Solid(kEdge, 0, 255, 0, 255)); + } + } // namespace +} // namespace MGITest From 8966d5c29429345c254d3639d53799b64bedcb35 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 04:28:22 -0400 Subject: [PATCH 2/4] [Fix] (Espryt, Magma): a partial write into a level whose upload is converted (widened RGB formats, Magma's RGB->RGBA expansion) or reshaped (Espryt 1D arrays) uploads only its dirty region - the rest of the stale shadow no longer lands over GPU-drawn texels --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 98 ++++++++++++------- .../Renderer/VkTextureManager.cpp | 36 ++++--- 2 files changed, 82 insertions(+), 52 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index bd1f76f2..f49141cf 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -3090,7 +3090,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // they use, so the type stays trivially default-constructible and the // declaration costs nothing on the levels that never reach the ring. struct UnpackStagingBlock { - const Uint8* src; // top-left texel of the region in the level shadow + const Uint8* src; // top-left texel of the region in the level's upload bytes SizeT rowBytes; // bytes per region row (region width * bpp) SizeT rows; // region height SizeT slices; // region depth (1 for 2D) @@ -4376,33 +4376,50 @@ namespace MobileGL::MG_Backend::DirectGLES { Vector packedUploadData; uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize, uploadData, byteSize, &glType, packedUploadData); - // Leaves `uploadData` pointing at its own buffer when it fires, which - // is exactly what takes the sub-rect fast path below out of play: that - // path strides into the SHADOW, and the widened texels are four - // components wide where the shadow's are one or two. + // Leaves `uploadData` pointing at its own buffer when it fires, with + // texels four components wide where the shadow's are one or two. Vector imageWidenedUploadData; uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize, imageWidenedUploadData); const IntVec3 uploadSize = GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize); // Sub-rect upload: when only a region of the level changed (a - // 16x16 sprite in a 1024x512 atlas, the per-frame lightmap) and - // the shadow bytes go to the driver unconverted, upload just that - // region with UNPACK_ROW_LENGTH striding into the level shadow. - // Conversion fallbacks rewrite the whole level into a fresh - // buffer, so they stay on the full-level path, as do targets - // whose backend upload size differs from the shadow's texel size. + // 16x16 sprite in a 1024x512 atlas, the per-frame lightmap), upload + // just that region with UNPACK_ROW_LENGTH striding into the level. + // Not an optimization alone: the shadow never sees a GPU write, so + // the texels OUTSIDE the region may be stale there and must not go + // up over what the GPU drew. Every conversion above rewrites the + // whole level texel for texel into a tight buffer of its own, so the + // region is taken from whichever buffer `uploadData` now names, at + // that buffer's bytes per texel. + SizeT uploadBytes = 0; + if (uploadData == mipData) { + uploadBytes = byteSize; + } else if (!imageWidenedUploadData.empty() && + uploadData == imageWidenedUploadData.data()) { + uploadBytes = imageWidenedUploadData.size(); + } else if (!packedUploadData.empty() && uploadData == packedUploadData.data()) { + uploadBytes = packedUploadData.size(); + } else if (!widenedUploadData.empty() && uploadData == widenedUploadData.data()) { + uploadBytes = widenedUploadData.size(); + } else if (!convertedUploadData.empty() && uploadData == convertedUploadData.data()) { + uploadBytes = convertedUploadData.size() * sizeof(Float); + } + // A 1D array's ES image is a 2D array one texel high with the layers + // in depth, while the shadow keeps the layers as rows: same texels in + // the same order, so a region maps across by moving its y to z. + const Bool layersAsRows = stateTextureObject->GetTarget() == TextureTarget::Texture1DArray; const auto dirtyRegion = textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level); const SizeT texelCount = static_cast(texelSize.x()) * static_cast(texelSize.y()) * static_cast(std::max(texelSize.z(), 1)); const Bool subRectEligible = - uploadData == mipData && !dirtyRegion.Empty() && - !dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 && - byteSize % texelCount == 0 && uploadSize.x() == texelSize.x() && - uploadSize.y() == texelSize.y() && - std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1); - const SizeT bpp = subRectEligible ? byteSize / texelCount : 0; + !dirtyRegion.Empty() && !dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 && + uploadBytes > 0 && uploadBytes % texelCount == 0 && + (layersAsRows ? std::max(texelSize.z(), 1) == 1 + : uploadSize.x() == texelSize.x() && uploadSize.y() == texelSize.y() && + std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1)); + const SizeT bpp = subRectEligible ? uploadBytes / texelCount : 0; const IntVec3 regionSize = {dirtyRegion.hi.x() - dirtyRegion.lo.x(), dirtyRegion.hi.y() - dirtyRegion.lo.y(), dirtyRegion.hi.z() - dirtyRegion.lo.z()}; @@ -4418,7 +4435,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // whole level while the touched texels are a few percent of // it. The storage's bounded rect list recovers the true // footprint; each rect is uploaded with the same - // ROW_LENGTH striding into the level shadow as the box + // ROW_LENGTH striding into the level as the box // path. The storage only hands the list out when its // summed area is materially smaller than the box (0 // otherwise), so the extra calls always move fewer bytes. @@ -4439,12 +4456,27 @@ namespace MobileGL::MG_Backend::DirectGLES { dirtyRectCount = 0; } } - const auto rectShadowPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) { + const auto rectUploadPtr = [&](const MG_State::GLState::MipmapDirtyRegion& rect) { return static_cast(uploadData) + static_cast(rect.lo.z()) * levelSliceBytes + static_cast(rect.lo.y()) * levelRowBytes + static_cast(rect.lo.x()) * bpp; }; + // A shadow-grid box as the ES image addresses it, and the image height + // that strides the source to match; they differ only for a 1D array + // (layersAsRows), whose rows are ES layers of a one-texel-high image. + struct BackendBox { + GLint x, y, z; + GLsizei w, h, d; + }; + const auto ToBackendBox = [&](const MG_State::GLState::MipmapDirtyRegion& rect) { + const GLsizei w = static_cast(rect.hi.x() - rect.lo.x()); + const GLsizei h = static_cast(rect.hi.y() - rect.lo.y()); + const GLsizei d = static_cast(rect.hi.z() - rect.lo.z()); + return layersAsRows ? BackendBox{rect.lo.x(), 0, rect.lo.y(), w, 1, h} + : BackendBox{rect.lo.x(), rect.lo.y(), rect.lo.z(), w, h, d}; + }; + const GLint imageHeight = layersAsRows ? 1 : texelSize.y(); // Unpack-ring staging plan, decided ONCE for whichever branch // below runs: either every glTexSubImage of this level sources // from the ring or none does, so the pixel-unpack binding is @@ -4469,7 +4501,7 @@ namespace MobileGL::MG_Backend::DirectGLES { for (SizeT r = 0; r < dirtyRectCount; ++r) { const auto& rect = dirtyRects[r]; stagingBlocks[r] = { - rectShadowPtr(rect), + rectUploadPtr(rect), static_cast(rect.hi.x() - rect.lo.x()) * bpp, static_cast(rect.hi.y() - rect.lo.y()), static_cast(std::max(rect.hi.z() - rect.lo.z(), 1)), @@ -4528,7 +4560,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(rect.hi.y() - rect.lo.y()), glFormat, glType, ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset) - : static_cast(rectShadowPtr(rect))); + : static_cast(rectUploadPtr(rect))); } // The surrounding ScopedDefaultUnpackState shadow says 0. if (!ringStaged) g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); @@ -4560,19 +4592,15 @@ namespace MobileGL::MG_Backend::DirectGLES { if (subRectEligible && dirtyRectCount >= 2) { if (!ringStaged) { g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x()); - g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y()); + g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, imageHeight); } for (SizeT r = 0; r < dirtyRectCount; ++r) { - const auto& rect = dirtyRects[r]; + const auto box = ToBackendBox(dirtyRects[r]); g_GLESFuncs.glTexSubImage3D( - glUploadTarget, static_cast(level), rect.lo.x(), - rect.lo.y(), rect.lo.z(), - static_cast(rect.hi.x() - rect.lo.x()), - static_cast(rect.hi.y() - rect.lo.y()), - static_cast(rect.hi.z() - rect.lo.z()), glFormat, - glType, + glUploadTarget, static_cast(level), box.x, box.y, box.z, + box.w, box.h, box.d, glFormat, glType, ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset) - : static_cast(rectShadowPtr(rect))); + : static_cast(rectUploadPtr(dirtyRects[r]))); } if (!ringStaged) { g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); @@ -4581,14 +4609,12 @@ namespace MobileGL::MG_Backend::DirectGLES { } else if (subRectEligible) { if (!ringStaged) { g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x()); - g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y()); + g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, imageHeight); } + const auto box = ToBackendBox(dirtyRegion); g_GLESFuncs.glTexSubImage3D( - glUploadTarget, static_cast(level), dirtyRegion.lo.x(), - dirtyRegion.lo.y(), dirtyRegion.lo.z(), - static_cast(regionSize.x()), - static_cast(regionSize.y()), - static_cast(regionSize.z()), glFormat, glType, + glUploadTarget, static_cast(level), box.x, box.y, box.z, box.w, box.h, + box.d, glFormat, glType, ringStaged ? UnpackRingPixelOffset(stagingBlocks[0].offset) : static_cast(regionPtr)); if (!ringStaged) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 222e7cd9..9aed0a4e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -2835,9 +2835,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDeviceSize offset = 0; // Sub-region upload (a small sprite in a big atlas): only the dirty box // is staged and copied. texelSize keeps the LEVEL extent - the staging - // row copy needs it for the shadow's stride. Plain color formats only; - // the RGB-expand and depth(+stencil) conversion passes rewrite whole - // levels and stay full-size. + // row copy needs it for the stride. Color formats only (an RGB-expanded + // level is cut from its expanded bytes); the depth(+stencil) conversion + // passes rewrite whole levels and stay full-size. Bool subRegion = false; IntVec3 regionLo = {0, 0, 0}; IntVec3 regionSize = {0, 0, 0}; @@ -2900,19 +2900,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { uploadItem.source = source; uploadItem.offset = stagingSize; uploadItem.uploadByteSize = byteSize; - if (!formatInfo.expandRgbToRgba && - GetAspectMaskForFormat(outResource.format) == VK_IMAGE_ASPECT_COLOR_BIT) { + // The RGB -> RGBA expansion rewrites the level texel for texel into a tight buffer + // of its own, so it runs first and the dirty region below is taken from whichever + // bytes are staged. Uploading an expanded level WHOLE put the shadow's stale texels + // over whatever the GPU had drawn into the rest of it. + if (formatInfo.expandRgbToRgba) { + const Bool expanded = ExpandRgbSourceToRgba(source, byteSize, texelSize, formatInfo, + uploadItem.expandedData); + MOBILEGL_ASSERT(expanded, + "UploadDirtyMipLevels: failed to expand RGB textureId=%d target=%s level=%u to RGBA staging data", + mipmapTexture.GetExternalIndex(), + MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level); + uploadItem.uploadByteSize = uploadItem.expandedData.size(); + } + const SizeT levelBytes = uploadItem.uploadByteSize; + if (GetAspectMaskForFormat(outResource.format) == VK_IMAGE_ASPECT_COLOR_BIT) { const auto region = mipmapTexture.GetStorageDirtyRegion(target, level); const SizeT texelCount = static_cast(texelSize.x()) * static_cast(texelSize.y()) * static_cast(std::max(texelSize.z(), 1)); if (!region.Empty() && !region.CoversWholeLevel(texelSize) && texelCount > 0 && - byteSize % texelCount == 0) { + levelBytes % texelCount == 0) { uploadItem.subRegion = true; uploadItem.regionLo = region.lo; uploadItem.regionSize = {region.hi.x() - region.lo.x(), region.hi.y() - region.lo.y(), region.hi.z() - region.lo.z()}; - uploadItem.texelBytes = byteSize / texelCount; + uploadItem.texelBytes = levelBytes / texelCount; uploadItem.uploadByteSize = static_cast(uploadItem.regionSize.x()) * static_cast(uploadItem.regionSize.y()) * static_cast(uploadItem.regionSize.z()) * @@ -2953,15 +2966,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } } - if (formatInfo.expandRgbToRgba) { - const Bool expanded = ExpandRgbSourceToRgba(source, byteSize, texelSize, formatInfo, - uploadItem.expandedData); - MOBILEGL_ASSERT(expanded, - "UploadDirtyMipLevels: failed to expand RGB textureId=%d target=%s level=%u to RGBA staging data", - mipmapTexture.GetExternalIndex(), - MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level); - uploadItem.uploadByteSize = uploadItem.expandedData.size(); - } uploadItems.push_back(Move(uploadItem)); if (!uploadItems.back().expandedData.empty()) { uploadItems.back().source = uploadItems.back().expandedData.data(); From b1d60523e4b34f9b8a6e290f10a41a4167ba0e8a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 04:28:55 -0400 Subject: [PATCH 3/4] [Fix] (Magma): an upload into an image the open recording already wrote, or that still has a queued clear, is submitted after that work - a glTexSubImage after a clear or a draw into the level no longer ends up under it --- .../Renderer/VkTextureManager.cpp | 43 +- .../DirectVulkan/Renderer/VkTextureManager.h | 26 ++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 110 ++++- .../DirectVulkan/Renderer/VulkanRenderer.h | 22 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../PartialUploadOverGpuContentScenario.cpp | 428 ++++++++++++++++++ 6 files changed, 623 insertions(+), 7 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 9aed0a4e..c00b0752 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -1293,6 +1293,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + VkTextureManager::TextureResource* VkTextureManager::FindTextureResource( + MG_State::GLState::ITextureObject& textureOrView) { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() ? &it->second : nullptr; + } + + Bool VkTextureManager::HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() && it->second.image != VK_NULL_HANDLE; + } + + Bool VkTextureManager::HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const { + auto& storage = StorageTextureOf(textureOrView); + auto it = m_textureResources.find(MakeTextureIdentity(&storage)); + if (it == m_textureResources.end() || it->second.image == VK_NULL_HANDLE) { + return false; + } + return it->second.syncedShapeVersion == storage.GetShapeVersion() && + it->second.syncedContentVersion != storage.GetContentVersion(); + } + + Bool VkTextureManager::WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() && it->second.lastRecordingWriteGeneration == m_recordingGeneration; + } + + void VkTextureManager::StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture) { + if (texture == nullptr) { + return; + } + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(*texture))); + if (it != m_textureResources.end()) { + StampResourceRecordingWrite(it->second); + } + } + void VkTextureManager::UpdateTrackedImageLayout(MG_State::GLState::ITextureObject* texture, VkImageLayout newLayout) { MOBILEGL_ASSERT(texture != nullptr, "UpdateTrackedImageLayout: texture is null"); auto it = m_textureResources.find(MakeTextureIdentity(texture)); @@ -1321,7 +1357,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { "UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d mipLevel=%u out of range %u", texture->GetExternalIndex(), writtenMipLevel, resource.mipLevels); // Pre-pass stream bookkeeping: the render pass that just ended wrote this image. - StampResourceRecordingUse(resource); + StampResourceRecordingWrite(resource); if (resource.layout != newLayout && resource.mipLevels > 1) { VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; @@ -1435,8 +1471,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource->aspect, 0, resource->mipLevels); MOBILEGL_ASSERT(ok, "TransitionTextureForStorageImage: transition failed for textureId=%d", texture.GetExternalIndex()); - // Pre-pass stream bookkeeping: a command referencing the image was recorded. - StampResourceRecordingUse(*resource); + // Pre-pass stream bookkeeping: a command referencing the image was recorded, and an + // image unit may store to it. + StampResourceRecordingWrite(*resource); return ok; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h index 117b8424..6e77a374 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h @@ -320,6 +320,11 @@ public: // into the frame's PRE command buffer - which executes strictly before the // frame's commands - instead of splitting the active render pass. Uint64 lastRecordingGeneration = 0; + // The same, for the last recorded command that WRITES the image (a render pass it is an + // attachment of, a clear, a blit/copy into it, a mip generation, a storage-image binding). + // An upload into an image written this recording has to be ordered after that write + // (VulkanRenderer::OrderPendingUploadAfterRecording). + Uint64 lastRecordingWriteGeneration = 0; // Snapshot of ITextureObject::GetContentVersion() at the last successful sync; // lets SyncTexture skip the whole re-check/re-upload when content is unchanged. Uint64 syncedContentVersion = 0; @@ -362,6 +367,7 @@ public: std::swap(this->storageUsageResolved, that.storageUsageResolved); std::swap(this->syncedTextureParamsVersion, that.syncedTextureParamsVersion); std::swap(this->lastRecordingGeneration, that.lastRecordingGeneration); + std::swap(this->lastRecordingWriteGeneration, that.lastRecordingWriteGeneration); std::swap(this->syncedContentVersion, that.syncedContentVersion); std::swap(this->syncedMipLevelCount, that.syncedMipLevelCount); std::swap(this->syncedShapeVersion, that.syncedShapeVersion); @@ -551,6 +557,26 @@ public: Bool WasTouchedThisRecording(const TextureResource& resource) const { return resource.lastRecordingGeneration == m_recordingGeneration; } + // A write is a use too. See TextureResource::lastRecordingWriteGeneration. + void StampResourceRecordingWrite(TextureResource& resource) const { + resource.lastRecordingGeneration = m_recordingGeneration; + resource.lastRecordingWriteGeneration = m_recordingGeneration; + } + void StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture); + // Upload-ordering queries (VulkanRenderer::OrderPendingUploadAfterRecording). None of them + // syncs, so nothing pending is uploaded by asking. All answer for the STORAGE texture. + // + // Uploads are recorded into a batch submitted AHEAD of the frame's recording + // (FlushPendingUploads), so an upload into an image the open recording already WROTE lands + // before that write, which then overwrites the newer texels. (An image the recording only + // read keeps the old trade: its earlier reads see the newer texels.) + Bool HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const; + // The next sync would record client texels into the image that is live now: same shape (a + // shape change mints a new image instead) and content moved since the last sync. + Bool HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const; + Bool WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const; + // The live resource, or null. + TextureResource* FindTextureResource(MG_State::GLState::ITextureObject& textureOrView); // Records that this texture is bound to a GL image unit, so its image must carry // VK_IMAGE_USAGE_STORAGE_BIT. Must be called before NeedsStorageImagePreparation, and // therefore before the render pass is committed: an image that has to be upgraded is diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b600511c..aa602e83 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -1801,6 +1801,9 @@ void main() { outBinding.label, texture->GetExternalIndex()); return false; } + if (!isReadFramebuffer) { + textureManager.StampResourceRecordingWrite(*resource); + } outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -1917,6 +1920,9 @@ void main() { outBinding.label, texture->GetExternalIndex(), static_cast(requiredAspectMask)); return false; } + if (!isReadFramebuffer) { + textureManager.StampResourceRecordingWrite(*resource); + } outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -1957,6 +1963,7 @@ void main() { texture.GetExternalIndex(), mipLevel, resource->mipLevels); return false; } + textureManager.StampResourceRecordingWrite(*resource); outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -6651,6 +6658,15 @@ void main() { VkRenderPassManager::EndRenderPass(frame.commandBuffer); activeRenderPass = nullptr; } + // The syncs below upload each sampled texture's pending texels into a batch that runs + // ahead of this recording; one whose image the recording already cleared or drew into + // would be overwritten by that earlier work. Submit it first, here, before anything below + // records into the frame or decides from the active pass. + for (auto* sampledTexture : sampledTextures) { + if (sampledTexture != nullptr && OrderPendingUploadAfterRecording(frame, *sampledTexture)) { + activeRenderPass = nullptr; + } + } Bool needSampledTextureTransitions = false; auto& sampledResources = m_sampledResourcesScratch; sampledResources.assign(sampledTextures.size(), nullptr); @@ -7349,6 +7365,7 @@ void main() { m_renderPassManager->QueueRenderbufferClear( {.mask = GL_COLOR_BUFFER_BIT, .color = payload.color}, attachment); } else if (attachment.IsTexture()) { + SettleTexelsBeforeQueuedClear(*fbo, attachment); m_clearManager->QueueClear({.mask = GL_COLOR_BUFFER_BIT, .color = payload.color}, attachment); } @@ -7360,6 +7377,19 @@ void main() { return; } + if ((deferredMask & GL_COLOR_BUFFER_BIT) != 0) { + for (const auto drawBuffer : fbo->GetDrawBuffers()) { + if (drawBuffer != FramebufferAttachmentType::None) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(drawBuffer)); + } + } + } + if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Depth)); + } + if ((deferredMask & GL_STENCIL_BUFFER_BIT) != 0) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Stencil)); + } m_clearManager->QueueClear(deferredMask, payload, *fbo); m_renderPassManager->QueueRenderbufferClear(deferredMask, payload, *fbo); } @@ -7437,6 +7467,7 @@ void main() { if (!attachment.IsTexture()) { return; } + SettleTexelsBeforeQueuedClear(framebuffer, attachment); m_clearManager->QueueClear(payload, attachment); }; @@ -7795,7 +7826,8 @@ void main() { } Bool VulkanRenderer::MaterializePendingClearForTexture(VkCommandBuffer commandBuffer, - MG_State::GLState::ITextureObject& texture) { + MG_State::GLState::ITextureObject& texture, + Bool uploadPendingTexels) { Vector pendingClears; if (!m_clearManager->GetPendingClears(&texture, pendingClears)) { return true; @@ -7807,8 +7839,9 @@ void main() { commandBuffer != m_frameContext.GetCurrent().commandBuffer, "MaterializePendingClearForTexture requires no active render pass on the target buffer"); - auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture); - if (resource == nullptr) { + auto* resource = uploadPendingTexels ? m_textureManager->SyncTextureAndGetDescriptor(texture) + : m_textureManager->FindTextureResource(texture); + if (resource == nullptr || resource->image == VK_NULL_HANDLE) { // Declined sync (an incomplete texture, say). Nothing to clear into, and every line // below dereferences this - the assert that used to stand here is compiled out of // every build past DEBUG. @@ -7960,6 +7993,9 @@ void main() { "MaterializePendingClearForTexture: failed to transition textureId=%d to sampled layout", texture.GetExternalIndex()); resource->layout = sampledLayout; + // The clear is now a write in this recording, which is what orders a later upload into + // the image after it (OrderPendingUploadAfterRecording). + m_textureManager->StampResourceRecordingWrite(*resource); m_clearManager->PopPendingClear(&texture); MGLOG_D("MaterializePendingClearForTexture: textureId=%d pending clear materialized", @@ -7967,6 +8003,62 @@ void main() { return true; } + Bool VulkanRenderer::OrderPendingUploadAfterRecording(FrameContext::FrameData& frame, + MG_State::GLState::ITextureObject& texture) { + if (!m_textureManager->HasPendingTexelUpload(texture)) { + return false; + } + // An open pass drawing into the image stamps that write only when it ends. + if (const auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); + activeRenderPass != nullptr && ActiveRenderPassUsesTexture(*activeRenderPass, texture)) { + VkRenderPassManager::EndRenderPass(frame.commandBuffer); + } + const Bool clearFirst = m_clearManager->HasPendingClear(&texture); + if (!clearFirst && !m_textureManager->WasTextureWrittenThisRecording(texture)) { + // Nothing recorded writes the image: the upload may run ahead of the recording. + return false; + } + if (!frame.isCommandRecording) { + m_frameContext.BeginCommandRecording(); + } + if (VkRenderPassManager::GetActiveRenderPass() != nullptr) { + VkRenderPassManager::EndRenderPass(frame.commandBuffer); + } + if (clearFirst && !MaterializePendingClearForTexture(frame.commandBuffer, texture, + /*uploadPendingTexels=*/false)) { + MGLOG_E_ONCE("OrderPendingUploadAfterRecording: textureId=%d kept its queued clear; it may land over " + "texels written after it", + texture.GetExternalIndex()); + } + if (!FlushPendingCommands()) { + MGLOG_E_ONCE("OrderPendingUploadAfterRecording: submit failed; textureId=%d's upload may run ahead of " + "earlier work on it", + texture.GetExternalIndex()); + return false; + } + m_frameContext.BeginCommandRecording(); + // New command buffer: the same reset SetupDraw applies when it begins one. + m_lastSampledSetValid = false; + return true; + } + + void VulkanRenderer::SettleTexelsBeforeQueuedClear( + const MG_State::GLState::FramebufferObject& framebuffer, + const MG_State::GLState::FramebufferAttachmentObject& attachment) { + // The default framebuffer's attachments are placeholders: its clears land on the + // swapchain image (MaterializePendingClearForDefaultFramebuffer) and it has no texels. + if (framebuffer.IsDefaultFramebuffer() || !attachment.IsTexture() || attachment.IsRenderbuffer()) { + return; + } + const auto texture = attachment.GetTexture(); + if (!texture || (m_textureManager->HasLiveImage(*texture) && + !m_textureManager->HasPendingTexelUpload(*texture))) { + return; + } + OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *texture); + m_textureManager->SyncTextureAndGetDescriptor(*texture); + } + Bool VulkanRenderer::MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer, MG_State::GLState::ITextureObject& texture, VkTextureManager::TextureResource& resource, @@ -9667,6 +9759,7 @@ void main() { const Bool dstClearReady = materializeClear(dstEndpoint); MOBILEGL_ASSERT(dstClearReady, "%s: failed to materialize pending clear for destination objectId=%u", __func__, CopyImageEndpointName(dstEndpoint)); + m_textureManager->StampTextureRecordingWrite(dstEndpoint.Texture.get()); const VkImageLayout srcOriginalLayout = *srcImage.trackedLayout; const VkImageLayout dstOriginalLayout = *dstImage.trackedLayout; @@ -9875,6 +9968,13 @@ void main() { } auto& frame = m_frameContext.GetCurrent(); + if (!readFbo->IsDefaultFramebuffer()) { + // The read syncs the source texture, whose pending texels must land after work already + // recorded against it (see OrderPendingUploadAfterRecording). + if (const auto readTexture = readFbo->GetAttachment(readFbo->GetReadBuffer()).GetTexture()) { + OrderPendingUploadAfterRecording(frame, *readTexture); + } + } if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); } @@ -10667,6 +10767,9 @@ void main() { return; } + // The sync below uploads pending texels, which must land after work already recorded + // against this image (see OrderPendingUploadAfterRecording). + OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *textureObject); auto* resource = m_textureManager->SyncTextureAndGetDescriptor(*textureObject); // Two shapes end up in the same place, and for the same reason: the GL level being read has // no GPU storage, so UploadDirtyMipLevels never wrote it and the CPU shadow is the ONLY copy @@ -10962,6 +11065,7 @@ void main() { resource = m_textureManager->SyncTextureAndGetDescriptor(*texture); MOBILEGL_ASSERT(resource != nullptr && resource->image != VK_NULL_HANDLE, "GenerateMipmap failed to resync the backend texture after allocating mip storage."); + m_textureManager->StampResourceRecordingWrite(*resource); if (resource->layout == VK_IMAGE_LAYOUT_UNDEFINED) { const VkImageLayout finalLayout = ResolveGenerateMipmapFinalLayout(resource->aspect); Bool transitioned = VkTextureManager::TransitionImageLayout( diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index c3d30855..42909bd2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -1376,8 +1376,28 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_State::GLState::ITextureObject& texture, Uint32 mipLevel, Uint32 depthSlice, const VkClearValue& clearValue, VkImageLayout finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + // Records the texture's queued clears. By default the texture is synced first, which + // uploads any pending texels BEFORE the clear - right only for texels written before it. + // `uploadPendingTexels = false` leaves them pending (OrderPendingUploadAfterRecording). Bool MaterializePendingClearForTexture(VkCommandBuffer commandBuffer, - MG_State::GLState::ITextureObject& texture); + MG_State::GLState::ITextureObject& texture, + Bool uploadPendingTexels = true); + // Texture uploads run in a batch submitted ahead of the frame's recording, so the upload + // the next sync of `texture` records would land BEFORE a write already recorded into the + // same image (a render, clear, blit or copy), and before a clear still queued for it (such + // a clear predates the texels: SettleTexelsBeforeQueuedClear). When either holds, this + // records the queued clear without the texels and submits the recording, so the upload + // follows both. An image the recording only READ keeps the old trade: no submit, and its + // earlier reads see the newer texels. It may end the render pass and replace + // frame.commandBuffer (recording again on return), so call it only where the caller holds + // no render-pass or command-buffer state across it. Returns whether it submitted. + Bool OrderPendingUploadAfterRecording(FrameContext::FrameData& frame, + MG_State::GLState::ITextureObject& texture); + // Called for each texture a glClear/glClearBuffer* is about to queue a clear on: uploads + // whatever texels are still pending (and mints the image if there is none), so that any + // texel pending when the clear is materialized was written after it. + void SettleTexelsBeforeQueuedClear(const MG_State::GLState::FramebufferObject& framebuffer, + const MG_State::GLState::FramebufferAttachmentObject& attachment); // The multisample arm of the above. Split out rather than branched inline because it // shares none of the transfer path: a multisample image carries no TRANSFER_DST usage, so // neither the TRANSFER_DST transition nor vkCmdClearColorImage is legal on one. diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 3c4d3498..e960b6fd 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -89,6 +89,7 @@ add_executable(MobileGLIntegrationTest Scenarios/NonCoreImageFormatScenario.cpp Scenarios/ImageSizeAfterRespecScenario.cpp Scenarios/ImageBindableRemintScenario.cpp + Scenarios/PartialUploadOverGpuContentScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp Scenarios/IoBlockNameCollisionScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp new file mode 100644 index 00000000..617ccf3a --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp @@ -0,0 +1,428 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A SMALL glTexSubImage INTO A LEVEL THE GPU HAS WRITTEN. +// +// Espryt keeps a CPU shadow of every level and syncs a pending client write by uploading the +// region it covers (SyncMipmapsToBackend's sub-rect path). The shadow never sees a GPU write, so +// only the region may go up: anything more puts the shadow's stale texels over what the GPU drew. +// The region upload used to be skipped whenever the shadow's bytes had to be CONVERTED on the way +// to the driver - a three-channel format the driver cannot render is stored four-channel and every +// upload is repacked - and whenever the ES upload is shaped differently from the shadow (a 1D +// array, stored as a 2D array one texel high): those levels went up WHOLE, and a 2x2 write erased +// the rest of the level. +// +// Every case uploads a level, lets the GPU clear it, writes one 2x2 box, and reads the level back: +// the clear outside the box, the write inside. The formats are picked by what they cost the ES +// upload, which is per driver - on Mesa llvmpipe GL_SRGB8, GL_RGB8_SNORM and GL_RGB8UI are stored +// four-channel (see ThreeChannelAttachmentScenario) while GL_RGB16F is native; ES has no renderable +// three-channel format at all, so on a phone all four are converted. GL_RGBA8 is the control that +// never converts. A format a backend cannot attach is skipped, not failed. +// +// The second half is about ORDER, with GL_RGBA8. Magma queues a clear until the texture's next +// use and records texel uploads into a batch submitted ahead of the frame's recorded commands, so +// a write issued after a clear of (or a draw into) the level reached the image FIRST and the +// earlier clear or draw landed over it: the box read back green. Each order is covered - the +// clear already recorded, still queued, synced by a draw or by the readback, the reverse order +// in which the clear must win, and a draw instead of a clear. + +#include +#include +#include +#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 int kEdge = 8; + constexpr int kBoxAt = 2; + constexpr int kBoxEdge = 2; + + constexpr const char* kVS = R"(#version 430 core +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); +} +)"; + + // How a value in [0, 1] is spelled in the client data, and how the level is cleared and + // read back. Integer levels spell 1 as kIntegerOne. + enum class Encoding { Unorm8, Snorm8, Float32, Uint8 }; + constexpr unsigned kIntegerOne = 9; + + struct Format { + GLenum internalFormat; + GLenum clientFormat; + GLenum clientType; + int components; + Encoding encoding; + }; + + std::vector Fill(const Format& f, int texels, float r, float g, float b) { + const float rgba[4] = {r, g, b, 1.0f}; + const std::size_t componentBytes = f.encoding == Encoding::Float32 ? 4u : 1u; + std::vector out(static_cast(texels) * f.components * componentBytes); + for (int t = 0; t < texels; ++t) { + for (int c = 0; c < f.components; ++c) { + std::uint8_t* dst = out.data() + (static_cast(t) * f.components + c) * componentBytes; + const float v = rgba[c]; + switch (f.encoding) { + case Encoding::Unorm8: *dst = static_cast(std::lround(v * 255.0f)); break; + case Encoding::Snorm8: + *dst = static_cast(static_cast(std::lround(v * 127.0f))); + break; + case Encoding::Float32: std::memcpy(dst, &v, sizeof(v)); break; + case Encoding::Uint8: *dst = static_cast(std::lround(v * kIntegerOne)); break; + } + } + } + return out; + } + + class PartialUploadOverGpuContentScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_scratch = MakeColorFbo(4, 4); + glGenVertexArrays(1, &m_vao); + glGenFramebuffers(1, &m_fbo); + FirstGLError(); + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + if (!m_textures.empty()) glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + if (m_vao) glDeleteVertexArrays(1, &m_vao); + if (m_fbo) glDeleteFramebuffers(1, &m_fbo); + BindDefaultFramebuffer(); + DestroyColorFbo(m_scratch); + glViewport(0, 0, Gl().Width(), Gl().Height()); + FirstGLError(); + } + + GLuint NewTexture(GLenum target) { + GLuint t = 0; + glGenTextures(1, &t); + m_textures.push_back(t); + glBindTexture(target, t); + glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, 0); + glBindTexture(target, 0); + return t; + } + + // A draw that samples the texture, which is what syncs its pending writes to the + // driver (an FBO attach alone may find the framebuffer already synced). + void SyncBySampling(GLenum target, GLuint tex, const char* samplerType, const char* coord) { + std::string fs = std::string("#version 430 core\nuniform ") + samplerType + + " u_tex;\nout vec4 o_color;\nvoid main() { o_color = vec4(texelFetch(u_tex, " + coord + + ", 0)); }\n"; + std::string error; + const GLuint program = CompileProgram(kVS, fs.c_str(), &error); + ASSERT_NE(program, 0u) << error; + m_programs.push_back(program); + BindFbo(m_scratch); + glUseProgram(program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(target, tex); + glUniform1i(glGetUniformLocation(program, "u_tex"), 0); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glBindTexture(target, 0); + glUseProgram(0); + BindDefaultFramebuffer(); + glFinish(); + EXPECT_EQ(FirstGLError(), 0u) << "sampling sync"; + } + + // Attaches (tex, layer) to the scratch framebuffer; false when this backend cannot. + bool Attach(GLuint tex, int layer) { + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + if (layer >= 0) { + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, layer); + } else { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + } + return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + } + + void Detach() { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0); + BindDefaultFramebuffer(); + } + + // The GPU write the shadow never sees: the whole level (or layer) to green. + void GpuClearGreen(const Format& f) { + if (f.encoding == Encoding::Uint8) { + const GLuint green[4] = {0u, kIntegerOne, 0u, 1u}; + glClearBufferuiv(GL_COLOR, 0, green); + } else { + const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + glClearBufferfv(GL_COLOR, 0, green); + } + } + + // The attached image's rgb, in units of "one", width x height texels. + std::vector ReadRgb(const Format& f, int width, int height) { + std::vector rgb(static_cast(width * height) * 3u); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + if (f.encoding == Encoding::Uint8) { + std::vector texels(static_cast(width * height) * 4u, 0xA5A5A5A5u); + glReadPixels(0, 0, width, height, GL_RGBA_INTEGER, GL_UNSIGNED_INT, texels.data()); + for (int i = 0; i < width * height; ++i) + for (int c = 0; c < 3; ++c) + rgb[i * 3 + c] = static_cast(texels[i * 4 + c]) / kIntegerOne; + } else { + std::vector texels(static_cast(width * height) * 4u, -7.0f); + glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, texels.data()); + for (int i = 0; i < width * height; ++i) + for (int c = 0; c < 3; ++c) rgb[i * 3 + c] = texels[i * 4 + c]; + } + EXPECT_EQ(FirstGLError(), 0u) << "readback"; + return rgb; + } + + // Green everywhere but blue inside [boxX, boxX + boxW) x [boxY, boxY + boxH). + static void ExpectClearAroundBox(const std::vector& rgb, int width, int height, int boxX, int boxY, + int boxW, int boxH) { + int bad = 0; + std::ostringstream first; + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) { + const bool inBox = x >= boxX && x < boxX + boxW && y >= boxY && y < boxY + boxH; + const float want[3] = {0.0f, inBox ? 0.0f : 1.0f, inBox ? 1.0f : 0.0f}; + const float* got = &rgb[static_cast(y * width + x) * 3u]; + bool texelBad = false; + for (int c = 0; c < 3; ++c) texelBad |= std::fabs(got[c] - want[c]) > 0.02f; + if (texelBad && bad++ == 0) { + first << "(" << x << "," << y << ") got " << got[0] << "," << got[1] << "," << got[2] + << " want " << want[0] << "," << want[1] << "," << want[2]; + } + } + EXPECT_EQ(bad, 0) << bad << " of " << width * height << " texels wrong; first " << first.str() + << " (red outside the box: the stale shadow went back up; green inside it: the " + "write landed before the clear or draw)"; + } + + // Upload red, sync, clear green on the GPU, sync, write a blue 2x2 box, sync, read. + void Run2D(const Format& f) { + const GLuint t = NewTexture(GL_TEXTURE_2D); + const bool integer = f.encoding == Encoding::Uint8; + const char* sampler = integer ? "usampler2D" : "sampler2D"; + const auto red = Fill(f, kEdge * kEdge, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_2D, t); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, static_cast(f.internalFormat), kEdge, kEdge, 0, f.clientFormat, + f.clientType, red.data()); + glBindTexture(GL_TEXTURE_2D, 0); + ASSERT_EQ(FirstGLError(), 0u) << "definition"; + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + if (!Attach(t, -1)) { + Detach(); + GTEST_SKIP() << "the format does not attach on backend " << Gl().BackendName(); + } + GpuClearGreen(f); + Detach(); + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + const auto blue = Fill(f, kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, f.clientFormat, f.clientType, + blue.data()); + glBindTexture(GL_TEXTURE_2D, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + ASSERT_TRUE(Attach(t, -1)); + const auto rgb = ReadRgb(f, kEdge, kEdge); + Detach(); + ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge); + } + + // Building blocks of the RGBA8 ordering cases: the same texels, issued in the orders + // a backend that defers work may get wrong. + static Format Rgba8() { return {GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}; } + + GLuint NewRedRgba8() { + const GLuint t = NewTexture(GL_TEXTURE_2D); + const auto red = Fill(Rgba8(), kEdge * kEdge, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data()); + glBindTexture(GL_TEXTURE_2D, 0); + EXPECT_EQ(FirstGLError(), 0u) << "definition"; + return t; + } + + void SampleRgba8(GLuint t) { SyncBySampling(GL_TEXTURE_2D, t, "sampler2D", "ivec2(0)"); } + + void ClearRgba8Green(GLuint t) { + ASSERT_TRUE(Attach(t, -1)); + GpuClearGreen(Rgba8()); + Detach(); + } + + // A draw that writes green over the whole level. + void DrawRgba8Green(GLuint t) { + std::string error; + const GLuint program = CompileProgram( + kVS, "#version 430 core\nout vec4 o_color;\nvoid main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }\n", + &error); + ASSERT_NE(program, 0u) << error; + m_programs.push_back(program); + ASSERT_TRUE(Attach(t, -1)); + glViewport(0, 0, kEdge, kEdge); + glUseProgram(program); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glUseProgram(0); + Detach(); + glViewport(0, 0, Gl().Width(), Gl().Height()); + } + + void WriteRgba8BlueBox(GLuint t) { + const auto blue = Fill(Rgba8(), kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, GL_RGBA, GL_UNSIGNED_BYTE, + blue.data()); + glBindTexture(GL_TEXTURE_2D, 0); + } + + // Green everywhere, and blue in the box when `boxWins`. + void ExpectRgba8(GLuint t, bool boxWins) { + ASSERT_TRUE(Attach(t, -1)); + const auto rgb = ReadRgb(Rgba8(), kEdge, kEdge); + Detach(); + ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, boxWins ? kBoxEdge : 0, boxWins ? kBoxEdge : 0); + } + + GLuint m_vao = 0, m_fbo = 0; + ColorFbo m_scratch{}; + std::vector m_programs; + std::vector m_textures; + }; + + // Control: the shadow's bytes go to the driver as they are. + TEST_F(PartialUploadOverGpuContentScenario, Rgba8SubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Srgb8SubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE, 3, Encoding::Unorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb8SnormSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB8_SNORM, GL_RGB, GL_BYTE, 3, Encoding::Snorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb16fSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB16F, GL_RGB, GL_FLOAT, 3, Encoding::Float32}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb8uiSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB8UI, GL_RGB_INTEGER, GL_UNSIGNED_BYTE, 3, Encoding::Uint8}); + } + + // A 1D array's ES image is a 2D array one texel high with the layers in depth, so the + // upload is shaped differently from the shadow (layers as rows) even with no conversion. + TEST_F(PartialUploadOverGpuContentScenario, OneDimensionalArraySubImageKeepsTheGpuClearedLayer) { + if (!Ready()) return; + constexpr int kLayers = 2; + const Format f{GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}; + const GLuint t = NewTexture(GL_TEXTURE_1D_ARRAY); + const auto red = Fill(f, kEdge * kLayers, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_1D_ARRAY, t); + glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_RGBA8, kEdge, kLayers, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data()); + glBindTexture(GL_TEXTURE_1D_ARRAY, 0); + ASSERT_EQ(FirstGLError(), 0u) << "definition"; + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + if (!Attach(t, 1)) { + Detach(); + GTEST_SKIP() << "a 1D array layer does not attach on backend " << Gl().BackendName(); + } + GpuClearGreen(f); + Detach(); + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + const auto blue = Fill(f, kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_1D_ARRAY, t); + glTexSubImage2D(GL_TEXTURE_1D_ARRAY, 0, kBoxAt, 1, kBoxEdge, 1, GL_RGBA, GL_UNSIGNED_BYTE, blue.data()); + glBindTexture(GL_TEXTURE_1D_ARRAY, 0); + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + ASSERT_TRUE(Attach(t, 1)); + const auto layer1 = ReadRgb(f, kEdge, 1); + Detach(); + ExpectClearAroundBox(layer1, kEdge, 1, kBoxAt, 0, kBoxEdge, 1); + } + + // The orders below matter to a backend that defers GPU work (Magma queues clears and runs + // texel uploads in a batch ahead of the frame's recorded commands); every one is plain GL. + + // The clear is still queued when the write arrives: it has to reach the level first. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClear) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + ClearRgba8Green(t); + WriteRgba8BlueBox(t); + SampleRgba8(t); + ExpectRgba8(t, true); + } + + // Same, with the readback as the first thing to sync the write. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClearForAReadback) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + ClearRgba8Green(t); + WriteRgba8BlueBox(t); + ExpectRgba8(t, true); + } + + // The other order: the write is still pending when the clear arrives, and the clear wins. + TEST_F(PartialUploadOverGpuContentScenario, ClearAfterAPendingSubImageWins) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + WriteRgba8BlueBox(t); + ClearRgba8Green(t); + SampleRgba8(t); + ExpectRgba8(t, false); + } + + // A draw into the level instead of a clear. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterADrawIntoTheLevelLandsOnTheDraw) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + DrawRgba8Green(t); + WriteRgba8BlueBox(t); + SampleRgba8(t); + ExpectRgba8(t, true); + } + } // namespace +} // namespace MGITest From c60639db1d90330ec0d9bc76c6e79107f4e2b200 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 05:51:18 -0400 Subject: [PATCH 4/4] [Fix] (Magma): eglSwapInterval reaches the swapchain - 0 picks IMMEDIATE, else MAILBOX, else FIFO; >= 1 FIFO; < 0 FIFO_RELAXED, else FIFO; rebuilt after the next present when the mode changes (MC 26.3 vsync on: 190-290 fps -> 120) --- .../BackendObject_DirectVulkan.cpp | 11 +++- .../DirectVulkan/BackendObject_DirectVulkan.h | 1 + .../MG_Backend/DirectVulkan/DirectVulkan.cpp | 15 +++++ .../MG_Backend/DirectVulkan/DirectVulkan.h | 4 ++ .../DirectVulkan/Renderer/SwapchainObject.cpp | 38 ++++++++++--- .../DirectVulkan/Renderer/SwapchainObject.h | 16 +++++- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 34 ++++++++++- .../DirectVulkan/Renderer/VulkanRenderer.h | 11 ++++ .../DirectVulkan/VulkanRendererConfig.h | 3 + MobileGL/MG_Test/CMakeLists.txt | 1 + MobileGL/MG_Test/Swapchain/CMakeLists.txt | 26 +++++++++ .../Swapchain/SwapchainPresentModeTest.cpp | 57 +++++++++++++++++++ 12 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 MobileGL/MG_Test/Swapchain/CMakeLists.txt create mode 100644 MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index e8ce9d1c..6966b05e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -344,7 +344,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Any renderer instance this assignment replaces is destroyed here; // fence/timer-query handles stamped with the old generation go stale. BumpRendererGeneration(); - pVulkanRenderer = MakeUnique(nativeWindow); + VulkanRendererConfig config; + config.SwapInterval = GetRequestedSwapInterval(); + pVulkanRenderer = MakeUnique(nativeWindow, config); MOBILEGL_ASSERT(pVulkanRenderer != nullptr, "InitWindowSurface: VulkanRenderer creation failed"); pVulkanRenderer->Initialize(); return true; @@ -455,6 +457,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { return BackendObject::SwapEGLBuffers(dpy, draw); } + void BackendObject_DirectVulkan::SetEGLSwapInterval(Int interval) { + // Same lock as SwapEGLBuffers: the renderer reads the request in Present. + const std::lock_guard lock(m_eglStateMutex); + BackendObject::SetEGLSwapInterval(interval); + } + void BackendObject_DirectVulkan::ReleaseEGLSurface(EGLSurface surface) { const std::lock_guard lock(m_eglStateMutex); BackendObject::ReleaseEGLSurface(surface); @@ -696,6 +704,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { static Bool funcsTableInitialized = false; if (!funcsTableInitialized) { funcsTable.Present = Present; + funcsTable.SetSwapInterval = SetSwapInterval; funcsTable.GL.DrawArrays = DrawArrays; funcsTable.GL.DrawElements = DrawElements; funcsTable.GL.DrawElementsBaseVertex = DrawElementsBaseVertex; diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h index 1c7ef15c..30a14d59 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h @@ -34,6 +34,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) override; Bool MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) override; Bool SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) override; + void SetEGLSwapInterval(Int interval) override; void ReleaseEGLSurface(EGLSurface surface) override; void ReleaseEGLResources() override; diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index f135a4aa..cae6e388 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -1431,4 +1431,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Present called with null VulkanRenderer"); pVulkanRenderer->Present(); } + + namespace { + Optional g_requestedSwapInterval; + } // namespace + + void SetSwapInterval(Int interval) { + g_requestedSwapInterval = interval; + if (pVulkanRenderer) { + pVulkanRenderer->SetSwapInterval(interval); + } + } + + Optional GetRequestedSwapInterval() { + return g_requestedSwapInterval; + } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h index 74241e81..28e3b464 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h @@ -137,4 +137,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { // only exist as vkCmdWriteTimestamp results); the frontend falls back. Int64 GetGpuTimestampNs(); void Present(); + // eglSwapInterval. Remembered across renderers: one created for a later window surface + // starts from the app's last request (GetRequestedSwapInterval) instead of the default. + void SetSwapInterval(Int interval); + Optional GetRequestedSwapInterval(); } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp index a2743a16..e79f4088 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp @@ -8,6 +8,8 @@ #include "SwapchainObject.h" +#include + #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" @@ -135,12 +137,26 @@ namespace MobileGL::MG_Backend::DirectVulkan { } VkPresentModeKHR SwapchainObject::ChooseSwapchainPresentMode( - const Vector& availablePresentModes) { - for (auto desiredPresentMode : s_desiredPresentModes) { - for (const auto& presentMode : availablePresentModes) { - if (presentMode == desiredPresentMode) { - return presentMode; - } + const Vector& availablePresentModes, Optional swapInterval) { + static constexpr VkPresentModeKHR s_unthrottledPresentModes[]{ + VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_FIFO_KHR}; + static constexpr VkPresentModeKHR s_vsyncPresentModes[]{VK_PRESENT_MODE_FIFO_KHR}; + static constexpr VkPresentModeKHR s_adaptiveVsyncPresentModes[]{VK_PRESENT_MODE_FIFO_RELAXED_KHR, + VK_PRESENT_MODE_FIFO_KHR}; + std::span ranked = s_desiredPresentModes; + if (swapInterval) { + if (*swapInterval == 0) { + ranked = s_unthrottledPresentModes; + } else if (*swapInterval > 0) { + ranked = s_vsyncPresentModes; + } else { + ranked = s_adaptiveVsyncPresentModes; + } + } + for (auto desiredPresentMode : ranked) { + if (std::find(availablePresentModes.begin(), availablePresentModes.end(), desiredPresentMode) != + availablePresentModes.end()) { + return desiredPresentMode; } } @@ -148,9 +164,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { return availablePresentModes[0]; } + const char* SwapchainObject::GetPresentModeName(VkPresentModeKHR presentMode) { + return string_VkPresentModeKHR(presentMode); + } + void SwapchainObject::Create(VkDevice device, VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, Uint32 graphicsQueueFamily, Uint32 presentQueueFamily, Uint32 minImageCountHint, - VkExtent2D desiredExtent) { + VkExtent2D desiredExtent, Optional swapInterval) { const auto swapchainCapabilities = GetSwapchainCapabilities(physicalDevice, surface); MOBILEGL_ASSERT(swapchainCapabilities.IsComplete(), "SwapchainObject::Create failed: incomplete swapchain capabilities"); @@ -169,8 +189,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { MGLOG_D(" %s", string_VkPresentModeKHR(pm)); } - const auto presentMode = ChooseSwapchainPresentMode(swapchainCapabilities.presentModes); + const auto presentMode = ChooseSwapchainPresentMode(swapchainCapabilities.presentModes, swapInterval); MGLOG_I("Picked present mode: %s", string_VkPresentModeKHR(presentMode)); + m_presentMode = presentMode; + m_supportedPresentModes = swapchainCapabilities.presentModes; const auto& swapchainCaps = swapchainCapabilities.capabilities; Uint32 targetImageCount = std::max(minImageCountHint, swapchainCaps.minImageCount); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.h index c22f07bd..4c36f43e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.h @@ -26,13 +26,23 @@ namespace MobileGL::MG_Backend::DirectVulkan { static SwapchainCapabilities GetSwapchainCapabilities(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface); static VkSurfaceFormatKHR ChooseSwapchainSurfaceFormat(const Vector& availableFormats); - static VkPresentModeKHR ChooseSwapchainPresentMode(const Vector& availablePresentModes); + // swapInterval is the app's eglSwapInterval, empty until it asks (s_desiredPresentModes + // ranks the modes then). 0 wants IMMEDIATE, else MAILBOX, else FIFO; >= 1 wants FIFO, + // which cannot skip vblanks, so 2 and up pace like 1; a negative interval (adaptive + // vsync) wants FIFO_RELAXED, else FIFO. + static VkPresentModeKHR ChooseSwapchainPresentMode(const Vector& availablePresentModes, + Optional swapInterval = Nullopt); + static const char* GetPresentModeName(VkPresentModeKHR presentMode); void Create(VkDevice device, VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, Uint32 graphicsQueueFamily, - Uint32 presentQueueFamily, Uint32 minImageCountHint, VkExtent2D desiredExtent); + Uint32 presentQueueFamily, Uint32 minImageCountHint, VkExtent2D desiredExtent, + Optional swapInterval); void Shutdown(VkDevice device); VkSwapchainKHR GetHandle() const { return m_swapchain; } + VkPresentModeKHR GetPresentMode() const { return m_presentMode; } + // The surface's present modes as queried when this swapchain was created. + const Vector& GetSupportedPresentModes() const { return m_supportedPresentModes; } const VkSurfaceFormatKHR& GetSurfaceFormat() const { return m_surfaceFormat; } VkExtent2D GetExtent() const { return m_extent; } // Surface-space extent (before the pre-rotation quarter-turn swap) this swapchain was @@ -79,6 +89,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; VkSwapchainKHR m_swapchain = VK_NULL_HANDLE; + VkPresentModeKHR m_presentMode = VK_PRESENT_MODE_FIFO_KHR; + Vector m_supportedPresentModes; VkSurfaceFormatKHR m_surfaceFormat{}; VkExtent2D m_extent{}; VkExtent2D m_surfaceExtent{}; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index aa602e83..b8369408 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -2980,7 +2980,10 @@ void main() { } VulkanRenderer::VulkanRenderer(NativeWindowType window, const VulkanRendererConfig& cfg) - : m_window(window), m_config(cfg) { + : m_window(window), m_config(cfg), m_presentsToAppWindow(window != NativeWindowType{}) { + if (!m_presentsToAppWindow) { + m_config.SwapInterval.reset(); + } // Initialize(); } @@ -12957,8 +12960,22 @@ void main() { if (!m_swapchainResizeRequested && SwapchainIsOutOfDate()) { m_swapchainResizeRequested = true; } + // A new eglSwapInterval lands here too, between frames: this one is on screen and the + // next has recorded nothing. Only a different present mode is worth the rebuild (it + // drops every pipeline); otherwise the live swapchain already paces as asked. + if (m_config.SwapInterval != m_swapchainSwapInterval) { + if (SwapchainObject::ChooseSwapchainPresentMode(m_swapchainObject.GetSupportedPresentModes(), + m_config.SwapInterval) != + m_swapchainObject.GetPresentMode()) { + m_swapchainResizeRequested = true; + } else { + MGLOG_D("DirectVulkan: swap interval %d -> %s", *m_config.SwapInterval, + SwapchainObject::GetPresentModeName(m_swapchainObject.GetPresentMode())); + m_swapchainSwapInterval = m_config.SwapInterval; + } + } if (m_swapchainResizeRequested) { - MGLOG_D("Present, processing requested swapchain resize"); + MGLOG_D("Present, processing requested swapchain rebuild (resize or swap interval)"); if (!RecreateSwapchain()) { m_presentSuspended = true; m_swapchainResizeRequested = false; @@ -14442,7 +14459,12 @@ void main() { m_swapchainObject.Create(m_device, m_physicalDevice.handle, m_surface, static_cast(m_physicalDevice.queueFamilies.graphicsFamily), static_cast(m_physicalDevice.queueFamilies.presentFamily), - m_config.MaxFramesInFlight, desiredExtent); + m_config.MaxFramesInFlight, desiredExtent, m_config.SwapInterval); + if (m_config.SwapInterval && m_config.SwapInterval != m_swapchainSwapInterval) { + MGLOG_D("DirectVulkan: swap interval %d -> %s", *m_config.SwapInterval, + SwapchainObject::GetPresentModeName(m_swapchainObject.GetPresentMode())); + } + m_swapchainSwapInterval = m_config.SwapInterval; // The FragCoordYFlip variants bake this height in; it is the only input to a shader // module that lives outside the GL program, so the factory has to learn it here (and on // every recreation, which is the only way it can change). @@ -14886,6 +14908,12 @@ void main() { m_swapchainResizeRequested = true; } + void VulkanRenderer::SetSwapInterval(Int interval) { + if (m_presentsToAppWindow) { + m_config.SwapInterval = interval; + } + } + VkInstance VulkanRenderer::GetInstance() const { return m_instance; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 42909bd2..cbcde43a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -348,6 +348,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool ResolveOcclusionQueryResult(const Vector& slots, Uint64& outSamples); void RequestSwapchainResize(Uint32 width, Uint32 height); + // eglSwapInterval. Records the request; Present rebuilds the swapchain for it once the + // current frame is on screen, and only when it maps to a different present mode. A + // renderer created without an app window ignores it: its surface (headless, or an + // AImageReader nobody consumes) is never displayed, and FIFO there would block once + // the reader's queue fills. + void SetSwapInterval(Int interval); // Re-query the surface and report whether the live swapchain no longer matches it // (size or orientation). This - not a VK_SUBOPTIMAL_KHR result - is what decides a // rebuild, so a surface the driver merely considers suboptimal cannot thrash. @@ -514,6 +520,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Shutdown() deletes it. void* m_fallbackImageReader = nullptr; VulkanRendererConfig m_config; + // Constructed for the app's window rather than a pbuffer (m_window is later filled in + // for the pbuffer fallbacks too, so it cannot tell them apart). + Bool m_presentsToAppWindow = false; + // The m_config.SwapInterval the live swapchain was built for; Present compares the two. + Optional m_swapchainSwapInterval; Bool m_swapchainResizeRequested = false; // Presentation is suspended while the window is zero-area (minimized): the // swapchain is unusable/out of date, so Present drops frames instead of diff --git a/MobileGL/MG_Backend/DirectVulkan/VulkanRendererConfig.h b/MobileGL/MG_Backend/DirectVulkan/VulkanRendererConfig.h index f7ed3ce4..27e6975d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/VulkanRendererConfig.h +++ b/MobileGL/MG_Backend/DirectVulkan/VulkanRendererConfig.h @@ -22,6 +22,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 CacheVersion = MG_Config::CacheVersion; Uint32 SurfaceWidth = 1; Uint32 SurfaceHeight = 1; + // The app's eglSwapInterval; empty until it asks. Window renderers only - see + // VulkanRenderer::SetSwapInterval. + Optional SwapInterval; Bool DisablePipelineCache = false; #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG Bool EnableValidationLayers = true; diff --git a/MobileGL/MG_Test/CMakeLists.txt b/MobileGL/MG_Test/CMakeLists.txt index 1fac40f3..394095de 100644 --- a/MobileGL/MG_Test/CMakeLists.txt +++ b/MobileGL/MG_Test/CMakeLists.txt @@ -85,6 +85,7 @@ add_subdirectory(VertexArray) add_subdirectory(Program) add_subdirectory(Query) add_subdirectory(Pipeline) +add_subdirectory(Swapchain) add_subdirectory(ShaderTranspiler) add_subdirectory(Util) add_subdirectory(SelfTest) diff --git a/MobileGL/MG_Test/Swapchain/CMakeLists.txt b/MobileGL/MG_Test/Swapchain/CMakeLists.txt new file mode 100644 index 00000000..4508be61 --- /dev/null +++ b/MobileGL/MG_Test/Swapchain/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.14) + +add_executable( + SwapchainPresentModeTest + SwapchainPresentModeTest.cpp +) + +target_include_directories(SwapchainPresentModeTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include +) + +target_link_libraries( + SwapchainPresentModeTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(SwapchainPresentModeTest PRIVATE /Zc:preprocessor) +endif() + +include(GoogleTest) +gtest_discover_tests(SwapchainPresentModeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.cpp b/MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.cpp new file mode 100644 index 00000000..46ff98ef --- /dev/null +++ b/MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.cpp @@ -0,0 +1,57 @@ +// MobileGL - MobileGL/MG_Test/Swapchain/SwapchainPresentModeTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include + +#include + +using namespace MobileGL; +using MobileGL::MG_Backend::DirectVulkan::SwapchainObject; + +namespace { + // What each surface reports. Android's loader exposes MAILBOX and FIFO (plus the shared + // modes with VK_KHR_shared_presentable_image) but never IMMEDIATE; Mesa's X11 WSI has + // all four classic modes. + const Vector kAndroidModes{ + VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_FIFO_KHR, VK_PRESENT_MODE_SHARED_DEMAND_REFRESH_KHR, + VK_PRESENT_MODE_SHARED_CONTINUOUS_REFRESH_KHR}; + const Vector kAllClassicModes{VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_MAILBOX_KHR, + VK_PRESENT_MODE_FIFO_KHR, VK_PRESENT_MODE_FIFO_RELAXED_KHR}; + const Vector kFifoOnly{VK_PRESENT_MODE_FIFO_KHR}; + + VkPresentModeKHR Choose(const Vector& modes, Optional swapInterval) { + return SwapchainObject::ChooseSwapchainPresentMode(modes, swapInterval); + } +} // namespace + +// Before the app calls eglSwapInterval the backend keeps its own ranking (MAILBOX first). +TEST(SwapchainPresentMode, NoRequestKeepsTheDefaultRanking) { + EXPECT_EQ(Choose(kAllClassicModes, Nullopt), VK_PRESENT_MODE_MAILBOX_KHR); + EXPECT_EQ(Choose(kAndroidModes, Nullopt), VK_PRESENT_MODE_MAILBOX_KHR); + EXPECT_EQ(Choose(kFifoOnly, Nullopt), VK_PRESENT_MODE_FIFO_KHR); +} + +TEST(SwapchainPresentMode, IntervalZeroPrefersImmediateThenMailboxThenFifo) { + EXPECT_EQ(Choose(kAllClassicModes, 0), VK_PRESENT_MODE_IMMEDIATE_KHR); + EXPECT_EQ(Choose(kAndroidModes, 0), VK_PRESENT_MODE_MAILBOX_KHR); + EXPECT_EQ(Choose({VK_PRESENT_MODE_FIFO_RELAXED_KHR, VK_PRESENT_MODE_FIFO_KHR}, 0), VK_PRESENT_MODE_FIFO_KHR); + EXPECT_EQ(Choose(kFifoOnly, 0), VK_PRESENT_MODE_FIFO_KHR); +} + +TEST(SwapchainPresentMode, PositiveIntervalIsFifo) { + for (Int interval : {1, 2, 4}) { + EXPECT_EQ(Choose(kAllClassicModes, interval), VK_PRESENT_MODE_FIFO_KHR) << "interval " << interval; + EXPECT_EQ(Choose(kAndroidModes, interval), VK_PRESENT_MODE_FIFO_KHR) << "interval " << interval; + } +} + +TEST(SwapchainPresentMode, NegativeIntervalIsFifoRelaxedWhenSupported) { + EXPECT_EQ(Choose(kAllClassicModes, -1), VK_PRESENT_MODE_FIFO_RELAXED_KHR); + EXPECT_EQ(Choose(kAndroidModes, -1), VK_PRESENT_MODE_FIFO_KHR); + EXPECT_EQ(Choose(kFifoOnly, -1), VK_PRESENT_MODE_FIFO_KHR); +}