diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp index fcc49b02..71432706 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -180,6 +180,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { sampleCount = VK_SAMPLE_COUNT_1_BIT; internalFormat = TextureInternalFormat::Unknown; samples = 0; + deadSinceFrame = kNeverObservedDead; } VkRenderPassManager::VkRenderPassManager(VkDevice device, @@ -214,21 +215,38 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void VkRenderPassManager::CollectRenderbufferGarbage() { - Vector deadRenderbuffers; - deadRenderbuffers.reserve(m_renderbufferResources.size()); - for (auto& [renderbuffer, resource] : m_renderbufferResources) { + // Two-phase reclamation: a dead renderbuffer's VkImage may still be referenced by + // command buffers submitted up to frames-in-flight frames ago (it was legally + // attached and drawn right up to its deletion), so the first observation of an + // expired weak reference only stamps the current frame counter; Destroy runs once + // enough frame boundaries have passed that the stamping frame's submission fence + // has provably been waited. MaxFramesInFlight + 2 covers the frame ring plus one + // boundary for the recording-to-submit gap and one because OnPresent runs ahead + // of Present's fence wait; the floor of 8 keeps a margin over the default ring of + // 3 while still releasing multi-MB attachment memory promptly (the render-pass + // cache's 1024-frame retirement would pin it for no additional safety). + const Uint64 retireAgeFrames = + std::max(8, static_cast(m_config.MaxFramesInFlight) + 2); + for (auto it = m_renderbufferResources.begin(); it != m_renderbufferResources.end();) { + auto& resource = it->second; const auto liveRenderbuffer = resource.renderbuffer.lock(); - if (!liveRenderbuffer || liveRenderbuffer.get() != renderbuffer) { - deadRenderbuffers.emplace_back(renderbuffer); + if (liveRenderbuffer && liveRenderbuffer.get() == it->first) { + resource.deadSinceFrame = RenderbufferResource::kNeverObservedDead; + ++it; + continue; } - } - for (auto* renderbuffer : deadRenderbuffers) { - auto resourceIt = m_renderbufferResources.find(renderbuffer); - if (resourceIt != m_renderbufferResources.end()) { - resourceIt->second.Destroy(m_device, m_allocator); - m_renderbufferResources.erase(resourceIt); + if (resource.deadSinceFrame == RenderbufferResource::kNeverObservedDead) { + resource.deadSinceFrame = m_frameCounter; + ++it; + continue; } - m_pendingRenderbufferClears.erase(renderbuffer); + if (m_frameCounter - resource.deadSinceFrame < retireAgeFrames) { + ++it; + continue; + } + m_pendingRenderbufferClears.erase(it->first); + resource.Destroy(m_device, m_allocator); + it = m_renderbufferResources.erase(it); } } @@ -270,6 +288,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource.samples != renderbuffer->GetSamples(); if (!needsCreate) { resource.renderbuffer = renderbuffer; + // A new renderbuffer at a recycled address may adopt a compatible entry that + // was already stamped dead; it is alive again, so cancel the aging. + resource.deadSinceFrame = RenderbufferResource::kNeverObservedDead; return &resource; } @@ -1178,6 +1199,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void VkRenderPassManager::OnPresent() { ++m_frameCounter; + // Runs every frame boundary, ahead of the render-pass sweep gate below: the walk + // is O(#renderbuffer resources) — single digits in practice — and per-frame + // invocation keeps dead-resource reclaim latency at the aging bound instead of + // coupling it to renderbuffer *use* (the GetOrCreateRenderbufferResource call + // site never runs again once an app stops using renderbuffers). + CollectRenderbufferGarbage(); + // Sweep occasionally; evict entries whose last use is far past every // in-flight frame so their VkRenderPass/VkFramebuffer can be destroyed // safely (RenderPassEntry's destructor releases the handles). diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h index 71f751a0..cdddd28a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h @@ -230,6 +230,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { public: struct RenderbufferResource { + // deadSinceFrame sentinel: the owning weak reference has not been observed + // expired. Dead resources age past every in-flight frame before Destroy + // (see CollectRenderbufferGarbage); the GPU may still reference the image + // for frames-in-flight frames after the GL object dies. + static constexpr Uint64 kNeverObservedDead = UINT64_MAX; + WeakPtr renderbuffer; VkImage image = VK_NULL_HANDLE; VmaAllocation allocation = nullptr; @@ -241,6 +247,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkSampleCountFlagBits sampleCount = VK_SAMPLE_COUNT_1_BIT; TextureInternalFormat internalFormat = TextureInternalFormat::Unknown; Int samples = 0; + // m_frameCounter value at which the weak reference was first seen expired. + Uint64 deadSinceFrame = kNeverObservedDead; void Destroy(VkDevice device, VmaAllocator allocator); }; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index a21dbccd..5a7dab65 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -627,6 +627,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { frameIndex, m_deferredViewReleases.size()); m_currentFrameIndex = frameIndex; CollectDeferredReleases(frameIndex); + + // Frame-boundary GC: every 64 frame boundaries (~1 s at 60 fps) bounds the reclaim + // latency for dead textures regardless of draw traffic — workloads that churn + // textures through clears/readbacks alone never reach the draw-gated + // CollectGarbage. Must run after CollectDeferredReleases above: the prune defers + // its releases into this frame's slot, which was just drained, so they are + // destroyed only after the slot's fence has been waited again one full frame-ring + // cycle from now (never while an in-flight frame may still reference them). + constexpr Uint32 kGcFrameInterval = 64; + ++m_gcFrameCounter; + if (m_gcFrameCounter % kGcFrameInterval == 0) { + PruneDeadTextures(); + } } void VkTextureManager::CollectAllDeferredReleases() { @@ -709,9 +722,22 @@ namespace MobileGL::MG_Backend::DirectVulkan { // construction introduces a new identity. Doing this unconditionally made every // sampled-texture sync scan the entire alive-texture map per draw. if (aliveIt == m_aliveObjects.end()) { + WeakPtr aliveTexture; const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex()); if (liveTexture && liveTexture.get() == &texture) { - m_aliveObjects[identity] = WeakPtr(liveTexture); + aliveTexture = liveTexture; + } else { + // The name lookup legally fails while the object is alive: the name was + // deleted with the texture still attached to an FBO (the attachment's + // SharedPtr keeps it alive), or the name was reused by a new texture, or + // this is a default texture object (name 0 lives outside the name map). + // Register through the object's own control block so the resource created + // below still participates in weak-expiry GC instead of becoming an + // orphan no reclamation path can reach until Shutdown. + aliveTexture = texture.weak_from_this(); + } + if (!aliveTexture.expired()) { + m_aliveObjects[identity] = Move(aliveTexture); PruneStaleTextureAliases(&texture); } } @@ -1219,10 +1245,22 @@ namespace MobileGL::MG_Backend::DirectVulkan { } SizeT VkTextureManager::CollectGarbage() { + // Draw-gated stagger (1 in 256 calls): keeps the per-draw cost at one counter + // bump. The guaranteed reclaim path is the frame-boundary prune in BeginFrame; + // this remains as a cheap assist so draw-heavy workloads reclaim sooner. m_gcCounter++; if (m_gcCounter != 0) { return 0; } + return PruneDeadTextures(); + } + + SizeT VkTextureManager::PruneDeadTextures() { + // Erasing entries would dangle the raw TextureResource pointers memoized for the + // current draw; every call path (BeginFrame, and CollectGarbage at the top of a + // freshly opened draw-sync scope) runs before any memo entry is recorded. + MOBILEGL_ASSERT(m_drawSyncedThisDraw.empty(), + "PruneDeadTextures: draw-sync memo holds raw resource pointers an erase would dangle"); Vector expiredTextures; expiredTextures.reserve(m_aliveObjects.size()); @@ -1234,7 +1272,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { for (auto* texture : expiredTextures) { PruneStaleTextureAliases(texture); } - return expiredTextures.size(); + SizeT prunedCount = expiredTextures.size(); + + // Orphan sweep: after the pass above, m_aliveObjects holds only live entries. + // Registration in SyncTextureAndGetDescriptor cannot fail for a SharedPtr-owned + // texture (weak_from_this fallback), so a resource whose identity has no alive + // entry has no trackable owner: its GL-side object is gone, or was never + // shared-owned, in which case recreation on a later sync is the safe fallback. + // Destruction goes through the per-frame deferred queues, never immediate. + Vector orphanIdentities; + for (auto it = m_textureResources.begin(); it != m_textureResources.end(); ++it) { + if (m_aliveObjects.find(it->first) == m_aliveObjects.end()) { + orphanIdentities.emplace_back(it->first); + } + } + for (const auto& identity : orphanIdentities) { + EraseTrackedTexture(identity); + } + prunedCount += orphanIdentities.size(); + return prunedCount; } Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h index 6bbef3fd..c56567c2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h @@ -368,6 +368,7 @@ private: static TextureIdentity MakeTextureIdentity(MG_State::GLState::ITextureObject* texture); void EraseTrackedTexture(const TextureIdentity& identity); void PruneStaleTextureAliases(MG_State::GLState::ITextureObject* texture); + SizeT PruneDeadTextures(); VkDevice m_device = VK_NULL_HANDLE; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; @@ -377,6 +378,9 @@ private: Uint32 m_currentFrameIndex = 0; Uint8 m_gcCounter = 0; + // Frame-boundary GC gate: counts BeginFrame calls, not draws, so texture churn + // through non-draw paths (FBO clears, readbacks) still reaches the prune. + Uint32 m_gcFrameCounter = 0; // Active only between BeginDrawSyncScope/EndDrawSyncScope; identities of // textures already fully synced in the current draw (small N -> flat scan). Bool m_drawSyncScopeActive = false; diff --git a/MobileGL/MG_State/GLState/TextureState/TextureObject.h b/MobileGL/MG_State/GLState/TextureState/TextureObject.h index 6bf9b9e7..652f9689 100644 --- a/MobileGL/MG_State/GLState/TextureState/TextureObject.h +++ b/MobileGL/MG_State/GLState/TextureState/TextureObject.h @@ -15,7 +15,11 @@ #include namespace MobileGL::MG_State::GLState { - class ITextureObject { + // Texture objects are always SharedPtr-owned (TextureState creates every instance via + // MakeShared, including the per-target default objects). enable_shared_from_this lets + // backends that only receive a reference (e.g. syncing a name-deleted texture kept + // alive by an FBO attachment) still register a weak liveness reference for GC. + class ITextureObject : public std::enable_shared_from_this { public: using TargetEnum = TextureTarget; virtual ~ITextureObject() = default;