From 855b3b50096a108baad2b81dc59ae508a2143bb2 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 8 Oct 2026 06:59:24 -0400 Subject: [PATCH] [Perf] (Magma): converted wire vertex streams are cached across frames and default-framebuffer clears stay standalone (BSL Magma 19.5 -> 77.2 fps, MobileGlues 78.2). --- MobileGL/Config.h | 11 + MobileGL/ConfigLoader.cpp | 3 + .../DirectVulkan/Renderer/FrameContext.cpp | 5 +- .../DirectVulkan/Renderer/VkBufferManager.cpp | 88 +++++- .../DirectVulkan/Renderer/VkBufferManager.h | 35 +++ .../Renderer/VkRenderPassManager.cpp | 19 ++ .../Renderer/VkRenderPassManager.h | 22 ++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 2 + .../DirectVulkan/Renderer/VulkanRenderer.h | 1 + .../DirectVulkan/Renderer/WireDraw.inc | 116 ++++++-- MobileGL/MG_IntegrationTest/CMakeLists.txt | 3 + .../ConvertedVertexStreamScenario.cpp | 261 ++++++++++++++++++ .../Scenarios/InPassClearScenario.cpp | 64 +++++ 13 files changed, 612 insertions(+), 18 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.cpp diff --git a/MobileGL/Config.h b/MobileGL/Config.h index a4956432..1aa549c6 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -125,6 +125,17 @@ namespace MobileGL::MG_Config { // (vkCmdClearAttachments) rather than in a render pass of its own built for the clear. // Auto is ON; ForceOff restores the standalone clear for every surface. QuirkOverride MagmaInPassClear = QuirkOverride::Auto; + // MOBILEGL_MAGMA_INPASS_CLEAR_DEFAULT: the same for the default framebuffer. Auto is OFF + // (an unexplained openra regression on Adreno 750, perf-p15/HANDOFF.md); ForceOn enables it. + QuirkOverride MagmaInPassClearDefault = QuirkOverride::Auto; + // MOBILEGL_MAGMA_PASS_CACHE: wire draw passes (render pass, framebuffer, views) are kept + // across frames while their key's image epochs hold. Auto is ON; ForceOff drops every + // cached pass whose last use completed at each frame begin, as before P15. + QuirkOverride MagmaPassCache = QuirkOverride::Auto; + // MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE: a wire vertex stream that needs a CPU format + // conversion is converted once per frame slot per (buffer content, layout) and reused by + // every draw that names it, instead of once per draw. Auto is ON; ForceOff converts per draw. + QuirkOverride MagmaVertexConversionCache = QuirkOverride::Auto; // MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension // string. `double` in a shader always WORKS - it is narrowed to 32 bits before any // module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index f3a688d5..6bdbef33 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -346,6 +346,9 @@ namespace MobileGL::MG_ConfigLoader { features.MagmaIterationRPFixBarrier = QueryEnvFlag("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER"); features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS"); features.MagmaInPassClear = QueryEnvQuirkOverride("MOBILEGL_MAGMA_INPASS_CLEAR"); + features.MagmaInPassClearDefault = QueryEnvQuirkOverride("MOBILEGL_MAGMA_INPASS_CLEAR_DEFAULT"); + features.MagmaPassCache = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PASS_CACHE"); + features.MagmaVertexConversionCache = QueryEnvQuirkOverride("MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE"); features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64"); features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK"); features.MagmaAllowInvalidSpirv = QueryEnvFlag("MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV"); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp index 2bc171d1..72cd9a0e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp @@ -108,7 +108,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkCommandBufferBeginInfo beginInfo{}; beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = flags; + // P15: a frame recording is submitted once and then reset, so the driver may skip whatever + // it keeps for re-submission. + beginInfo.flags = flags | VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; beginInfo.pInheritanceInfo = pInheritanceInfo; VK_VERIFY(vkBeginCommandBuffer(frame.commandBuffer, &beginInfo), "BeginCommandRecording, vkBeginCommandBuffer"); @@ -137,6 +139,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VK_VERIFY(vkResetCommandBuffer(frame.preCommandBuffer, 0), "BeginPreCommandRecording, vkResetCommandBuffer"); VkCommandBufferBeginInfo beginInfo{}; beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; VK_VERIFY(vkBeginCommandBuffer(frame.preCommandBuffer, &beginInfo), "BeginPreCommandRecording, vkBeginCommandBuffer"); frame.isPreCommandRecording = true; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index 02e3c849..4f9ea7a7 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -225,6 +225,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // to wait for a submission (DeferredWireRelease), and three lines from now the record // no longer carries it. const Uint64 orphanedUseSerial = resource->lastUseSerial; + resource->contentSerial = ++m_wireContentSerial; // P11 B2: a T0 store is ALWAYS released through the serial-gated list (never "at once"), // so the client's AHardwareBuffer outlives every submission that can still name it however // the client's own release (at this respecify) raced the frames in flight. @@ -312,6 +313,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { std::abort(); } if (size == 0) return; + resource->contentSerial = ++m_wireContentSerial; Bool uploaded = false; const Uint64 probeLastUse = resource->lastUseSerial; const Uint64 probeCompleted = GetCompletedSerial(); @@ -417,12 +419,91 @@ namespace MobileGL::MG_Backend::DirectVulkan { // ordered after earlier uses. There is no stale shadow to replay here. } + Bool VkBufferManager::LookupConvertedStream(const ConvertedStreamKey& key, SizeT elementCount, + BufferSlice& outSlice) { + for (auto& entry : m_convertedStreams) { + if (!(entry.key == key) || entry.elementCount < elementCount) continue; + entry.lastUseFrameSerial = m_frameSerial; + outSlice = entry.buffer.GetSlice(); + outSlice.mapped = nullptr; + return true; + } + return false; + } + + void VkBufferManager::RetireConvertedStream(SizeT index) { + auto& entry = m_convertedStreams[index]; + m_convertedStreamBytes -= static_cast(entry.buffer.GetSize()); + // Its last use may be this frame: the slot's next BeginFrame is after this frame completed. + if (m_currentFrameIndex < m_deferredBufferReleases.size()) + m_deferredBufferReleases[m_currentFrameIndex].push_back(std::move(entry.buffer)); + m_convertedStreams.erase(m_convertedStreams.begin() + static_cast(index)); + } + + Bool VkBufferManager::StoreConvertedStream(const ConvertedStreamKey& key, MG_Pipe::MGPipeHandle source, + SizeT elementCount, const void* bytes, SizeT size, + BufferSlice& outSlice) { + constexpr SizeT kConvertedStreamBudget = 192ull << 20; + if (size == 0 || size > kConvertedStreamBudget / 2 || m_initInfo.allocator == nullptr || + m_deferredBufferReleases.empty()) + return false; + // A newer conversion of the same store and layout supersedes the old one (its content + // serial can never come back); an entry unused for many frames (a deleted store's, a + // layout no longer drawn) goes too. + constexpr Uint64 kConvertedStreamIdleFrames = 256; + for (SizeT i = m_convertedStreams.size(); i-- > 0;) { + const auto& entry = m_convertedStreams[i]; + if (entry.lastUseFrameSerial + kConvertedStreamIdleFrames < m_frameSerial) { + RetireConvertedStream(i); + continue; + } + if (entry.source == source && entry.key.baseOffset == key.baseOffset && entry.key.stride == key.stride && + entry.key.type == key.type && entry.key.size == key.size && entry.key.conversion == key.conversion) + RetireConvertedStream(i); + } + while (!m_convertedStreams.empty() && m_convertedStreamBytes + size > kConvertedStreamBudget) { + SizeT oldest = 0; + for (SizeT i = 1; i < m_convertedStreams.size(); ++i) + if (m_convertedStreams[i].lastUseFrameSerial < m_convertedStreams[oldest].lastUseFrameSerial) oldest = i; + RetireConvertedStream(oldest); + } + ConvertedStream entry; + entry.key = key; + entry.source = source; + entry.elementCount = elementCount; + entry.lastUseFrameSerial = m_frameSerial; + if (!entry.buffer.Create({ + .allocator = m_initInfo.allocator, + .size = static_cast(size), + .usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, + .memoryUsage = VMA_MEMORY_USAGE_AUTO, + .allocationFlags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT, + .requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + }) || entry.buffer.Map() == nullptr || !entry.buffer.Upload(bytes, static_cast(size), 0)) + return false; + m_convertedStreamBytes += size; + outSlice = entry.buffer.GetSlice(); + outSlice.mapped = nullptr; + m_convertedStreams.push_back(std::move(entry)); + return true; + } + + Bool VkBufferManager::GetWireBufferContentSerial(MG_Pipe::MGPipeHandle res, Uint64& serial) { + auto* resource = FindWireBuffer(res); + if (!resource || resource->imported || !resource->buffer.IsValid()) return false; + serial = resource->contentSerial; + return true; + } + Bool VkBufferManager::AcquireWireSlice(BufferKind kind, MG_Pipe::MGPipeHandle res, BufferSlice& outSlice) { - (void)kind; // The store carries every buffer usage, so changing roles never orphans it. + // The store carries every buffer usage, so changing roles never orphans it. outSlice = {}; auto* resource = FindWireBuffer(res); if (!resource || !resource->buffer.IsValid() || resource->size == 0) return false; resource->lastUseSerial = m_frameSerial; + // A slice a shader may write through: whatever it writes is a new content state. + if (kind == BufferKind::ShaderStorage || kind == BufferKind::TextureBuffer) + resource->contentSerial = ++m_wireContentSerial; // B3: the draw this slice is being acquired for is normally recorded into the NEXT // submission - normally, not always: a mid-draw flush (SyncWireTextureShape's preserve // path) can submit this index without the draw. This stamp is the defence term's input @@ -581,6 +662,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (!resource || offset > resource->size) return; if (size == MG_Pipe::kMGPipeWholeBuffer) size = resource->size - offset; if (size == 0 || size > resource->size - offset) return; + resource->contentSerial = ++m_wireContentSerial; resource->lastUseSerial = m_frameSerial; resource->stagedCoverage.clear(); resource->gpuWritesPending = true; @@ -721,6 +803,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void* mapped = resource->buffer.GetMappedData(); if (mapped == nullptr) return nullptr; if (resource->imported) return mapped; // already donated: the same pointer + resource->contentSerial = ++m_wireContentSerial; if (!WaitForWireBufferHostAccess(*resource)) return nullptr; if (seedBytes != nullptr) std::memcpy(mapped, seedBytes, static_cast(size)); resource->imported = true; @@ -1448,6 +1531,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { void VkBufferManager::DestroyAllDeferredReleases() { // Both callers (Shutdown, RecreateTransientArenas) have proven the device idle. DestroyAllDeferredWireReleases(); + for (auto& stream : m_convertedStreams) stream.buffer.Destroy(); + m_convertedStreams.clear(); + m_convertedStreamBytes = 0; for (auto& releases : m_deferredBufferReleases) { for (auto& buffer : releases) { buffer.Destroy(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h index 98f9efb3..24b817c5 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h @@ -158,6 +158,28 @@ namespace MobileGL::MG_Backend::DirectVulkan { // that memo names can have been freed and re-minted in between, which is the one // fact a handle-keyed memo of wire descriptors needs and cannot read off the handle. Uint64 GetWireStoreDestroyEpoch() const { return m_wireStoreDestroyEpoch; } + // P15 S0: the store's content state, as a process-wide monotonic serial: it moves on every + // host write, respecify and GPU-write mark, and on every bind a shader may write through + // (storage / texture-buffer slices). False for a store whose bytes the client writes + // directly (an imported or donated store), which no cache may key on. + Bool GetWireBufferContentSerial(MG_Pipe::MGPipeHandle res, Uint64& serial); + // P15 S0: CONVERTED WIRE VERTEX STREAMS, KEPT ACROSS FRAMES (the frontend arm's + // ConvertedVertexStream cache, lost with it, and longer-lived than it: that one was per + // frame). Key: the source store's content serial - process-wide monotonic, so no store or + // handle identity can alias - and the layout. Each entry owns a vertex buffer holding the + // converted elements; a newer conversion of the same store and layout replaces it, and the + // whole set is bounded by bytes (least recently used first). A replaced or evicted buffer + // is released when the frame slot that last used it comes round again. + struct ConvertedStreamKey { + Uint64 contentSerial = 0; + Uint64 baseOffset = 0; + Uint32 stride = 0, type = 0, size = 0, conversion = 0; + Bool normalized = false, isInteger = false, isBgra = false; + Bool operator==(const ConvertedStreamKey&) const = default; + }; + Bool LookupConvertedStream(const ConvertedStreamKey& key, SizeT elementCount, BufferSlice& outSlice); + Bool StoreConvertedStream(const ConvertedStreamKey& key, MG_Pipe::MGPipeHandle source, SizeT elementCount, + const void* bytes, SizeT size, BufferSlice& outSlice); // Highest frame serial whose GPU work is known complete; serials at or // below it may be considered signaled. Drives IsResourceBusy and the // backend GL fence objects. @@ -173,6 +195,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { // unsubmitted index as incomplete, but the stamp is taken before the draw is // recorded and a mid-draw flush can submit it without the draw. Uint64 lastUseSubmitIndex = 0; + // P15 S0: see GetWireBufferContentSerial. + Uint64 contentSerial = 0; Bool gpuWritesPending = false; // Only ranges actually submitted by resource_subdata are covered. No // shadow is retained: flush cannot replay stale bytes over GPU writes. @@ -329,6 +353,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { // so that a memo taken before a re-initialize must not match // a handle minted after it. Uint64 m_wireStoreDestroyEpoch = 0; + Uint64 m_wireContentSerial = 0; + struct ConvertedStream { + ConvertedStreamKey key; + MG_Pipe::MGPipeHandle source = MG_Pipe::kMGPipeNullHandle; + SizeT elementCount = 0; + VkBufferObject buffer; + Uint64 lastUseFrameSerial = 0; + }; + Vector m_convertedStreams; + SizeT m_convertedStreamBytes = 0; + void RetireConvertedStream(SizeT index); Uint32 m_currentFrameIndex = 0; Uint64 m_frameSerial = 1; Uint64 m_completedSerialFloor = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp index cb7c7f6b..1f3675ca 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -105,6 +105,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { return true; } + Bool VkRenderPassManager::BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry, + VkRenderPass renderPass, const VkClearValue* clearValues, + Uint32 clearValueCount) { + VkRenderPassBeginInfo begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + begin.renderPass = renderPass; + begin.framebuffer = renderPassEntry.framebuffer; + begin.renderArea = {{0, 0}, {static_cast(renderPassEntry.extent.x()), + static_cast(renderPassEntry.extent.y())}}; + begin.clearValueCount = clearValueCount; + begin.pClearValues = clearValues; + vkCmdBeginRenderPass(commandBuffer, &begin, VK_SUBPASS_CONTENTS_INLINE); + s_activeRenderPass.Get().commandBuffer = commandBuffer; + s_activeRenderPass.Get().hash = renderPassEntry.hash; + s_activeRenderPass.Get().compatibilityHash = renderPassEntry.compatibilityHash; + s_activeRenderPass.Get().extent = renderPassEntry.extent; + s_hasActiveRenderPass.Get() = true; + return true; + } + Bool VkRenderPassManager::EndRenderPass(VkCommandBuffer commandBuffer) { BumpWireImageWriteEpoch(); vkCmdEndRenderPass(commandBuffer); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h index da47257e..aad7a0ee 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h @@ -47,6 +47,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 layers = 1; // Frame counter value of the last GetOrCreateRenderPass hit; drives cache eviction. Uint64 lastUsedFrame = 0; + // P15 S0 (wire passes only): what the render pass was created from, and the render + // passes that differ from it only in loading some attachments with LOAD_OP_CLEAR. Load ops + // are not part of render-pass compatibility, so a variant begins with this entry's own + // framebuffer and runs every pipeline created against `renderPass`. + Vector wireAttachments; + Vector wireColors; + VkAttachmentReference wireDepth{VK_ATTACHMENT_UNUSED, VK_IMAGE_LAYOUT_GENERAL}; + Vector> clearVariants; RenderPassEntry() = default; RenderPassEntry(const RenderPassEntry&) = delete; @@ -62,6 +70,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { std::swap(extent, that.extent); std::swap(layers, that.layers); std::swap(lastUsedFrame, that.lastUsedFrame); + std::swap(wireAttachments, that.wireAttachments); + std::swap(wireColors, that.wireColors); + std::swap(wireDepth, that.wireDepth); + std::swap(clearVariants, that.clearVariants); } // Move ASSIGNMENT, not just construction. The move constructor above and the // destructor below each independently suppress the implicit one, which left the @@ -94,10 +106,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { std::swap(extent, that.extent); std::swap(layers, that.layers); std::swap(lastUsedFrame, that.lastUsedFrame); + std::swap(wireAttachments, that.wireAttachments); + std::swap(wireColors, that.wireColors); + std::swap(wireDepth, that.wireDepth); + std::swap(clearVariants, that.clearVariants); } return *this; } ~RenderPassEntry() { + for (const auto& variant : clearVariants) + if (variant.second != VK_NULL_HANDLE) vkDestroyRenderPass(s_device.Get(), variant.second, nullptr); if (renderPass != VK_NULL_HANDLE) { vkDestroyRenderPass(s_device.Get(), renderPass, nullptr); } @@ -147,6 +165,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_defaultFramebufferTarget = serial; } static Bool BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry); + // P15 S0: begins `renderPassEntry` through `renderPass` (one of its clear-load variants) + // with these clear values; it is active as the entry itself, so a draw continues it. + static Bool BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry, + VkRenderPass renderPass, const VkClearValue* clearValues, Uint32 clearValueCount); static Bool EndRenderPass(VkCommandBuffer commandBuffer); static ActiveRenderPassInfo* GetActiveRenderPass(); private: diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 650f68c4..095c53c8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -2371,6 +2371,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { WireDeclineTally::Dump("shutdown"); // The SPIR-V screen's engagement proof: a decline-free session above reads as "screened // and valid" only with this count beside it (distinct modules, process-wide). + MGLOG_I("MGWIRE-VCONV[shutdown] converted=%llu reused=%llu", static_cast(m_wireStreamsConverted), + static_cast(m_wireStreamsReused)); MGLOG_I("MGWIRE-SPIRV-SCREEN[shutdown] modulesValidated=%llu", static_cast(ProgramFactory::DriverModuleValidatorRuns())); DestroyWireDrawPass(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index f29f2a0d..d7a59c3c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -880,6 +880,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool WirePassMemoHolds(const MG_Pipe::MGPFramebufferState& fbo, const FrameContext::FrameData& frame) const; Uint64 m_wirePassMarksClearGeneration = 0; WireDrawScratch m_wireDrawScratch; + Uint64 m_wireStreamsConverted = 0, m_wireStreamsReused = 0; Bool PrepareWireDrawPass(FrameContext::FrameData& frame, const MG_Pipe::MGPFramebufferState& fbo, WireDrawScratch& scratch, Bool passContinues, Bool& continuing); // P15: glClear / glClearBuffer* into the attachments of the wire draw pass, recorded inside it diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc index 855d838d..ca8ecfdf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc @@ -173,10 +173,14 @@ if (m_wireColorBlitResources) ResetWireColorBlitFramePool(frameIndex); const Uint64 imageEpoch = m_textureManager->GetTextureImageEpoch(); const Uint64 eraseEpoch = m_textureManager->GetResourceEraseEpoch(); + const Bool keepAcrossFrames = MG_Config::Features.MagmaPassCache != MG_Config::QuirkOverride::ForceOff; SizeT kept = 0; for (SizeT i = 0; i < m_wireDrawPassCache.size(); ++i) { auto& entry = m_wireDrawPassCache[i]; - if (entry.key.textureImageEpoch == imageEpoch && entry.key.resourceEraseEpoch == eraseEpoch) { + const Bool live = keepAcrossFrames ? entry.key.textureImageEpoch == imageEpoch && + entry.key.resourceEraseEpoch == eraseEpoch + : entry.lastUseSubmit > m_completedSubmitCounter; + if (live) { if (kept != i) m_wireDrawPassCache[kept] = Move(entry); ++kept; continue; @@ -426,6 +430,9 @@ created.extent = {static_cast(extent.width), static_cast(extent.height)}; created.layers = layers; created.hash = created.compatibilityHash = XXH64(colors.data(), colors.size() * sizeof(colors[0]), passHash); + created.wireAttachments.assign(attachments.begin(), attachments.end()); + created.wireColors.assign(colors.begin(), colors.end()); + created.wireDepth = depth; } // The attachments' GPU-write marks (MarkWireTextureGpuWritten): idempotent while nothing // clears a GPU-dirty mark, so a draw that continues a pass whose draws already marked the @@ -641,6 +648,19 @@ MGL_WIRE_DECLINE_AT(VertexStreamCountZero, "the converted vertex stream would hold no elements"); return false; } + // One conversion per (source content, layout), kept across frames until the source + // changes (VkBufferManager's converted-stream cache): a multi-draw over a chunk arena + // names the same stream for every chunk, frame after frame. + Uint64 contentSerial = 0; + const Bool cacheable = MG_Config::Features.MagmaVertexConversionCache != MG_Config::QuirkOverride::ForceOff && + m_bufferManager.GetWireBufferContentSerial(resource.Res, contentSerial); + const VkBufferManager::ConvertedStreamKey cacheKey{contentSerial, offset, static_cast(stride), + static_cast(attr.Type), static_cast(attr.Size), static_cast(conversion), + attr.Normalized != 0, attr.IsInteger != 0, attr.IsBgra != 0}; + if (cacheable && m_bufferManager.LookupConvertedStream(cacheKey, count, vertexSlices[binding])) { + ++m_wireStreamsReused; + continue; + } Vector source((count - 1) * stride + elementSize); if (!m_bufferManager.ReadWireBuffer(resource.Res, offset, source.size(), source.data())) { MGL_WIRE_DECLINE_AT(VertexStreamRead, "the vertex bytes to convert could not be read back"); @@ -664,7 +684,10 @@ WireDeclineTally::Count(WireDeclineSite::VertexStreamConverter); return false; } - if (!m_bufferManager.UploadTransient(BufferKind::Vertex, m_frameContext.GetCurrentFrameIndex(), + ++m_wireStreamsConverted; + if (!(cacheable && m_bufferManager.StoreConvertedStream(cacheKey, resource.Res, count, converted.data(), + converted.size(), vertexSlices[binding])) && + !m_bufferManager.UploadTransient(BufferKind::Vertex, m_frameContext.GetCurrentFrameIndex(), converted.data(), converted.size(), 16, vertexSlices[binding])) { MGL_WIRE_DECLINE_AT(VertexTransientUpload, "the converted vertex stream did not fit the transient arena"); return false; @@ -865,29 +888,40 @@ return true; } - // P15 S0: A CLEAR INTO THE OPEN PASS'S ATTACHMENTS IS RECORDED INSIDE THE PASS. + // P15 S0: A CLEAR INTO THE ATTACHMENTS OF THE DRAW PASS BECOMES PART OF THAT PASS. // // The standalone path (ClearWireFramebuffer's per-surface lambda) closes the draw pass, then // builds an image view, a render pass and a framebuffer for every cleared surface, runs a pass // of its own and retires all three: about 70 us a clear on Adreno 750, and every following draw // to the same framebuffer opens a new pass (FCL MC 1.21.5: 6 clears a frame, 0.42 ms). Here the // clear takes the pass a draw to this framebuffer would use (PrepareWireDrawPass: the open one, - // a cached one, or a new one through the same walk) and becomes a vkCmdClearAttachments in it, - // so the draws after it continue the same pass. + // a cached one, or a new one through the same walk), so the draws after it continue that pass: // - // vkCmdClearAttachments inside the subpass is ordered with the pass's draws as a draw is, writes - // through the same attachment views (the UNORM twin of an sRGB image while GL_FRAMEBUFFER_SRGB - // is off, which is exactly GL's raw clear - no pre-compensation), and needs no layout change: - // wire attachments live in GENERAL. What it cannot express goes back to the standalone path, - // per GL buffer bit: a partial colour or stencil write mask (that path warns and skips, as - // before), an attachment whose extent or layer count differs from the pass's (the pass covers the - // common area only), and a stencil clear the pass's depth attachment does not carry. + // * A clear of the whole render area that would BEGIN the pass begins it with LOAD_OP_CLEAR for + // the cleared attachments (a clear-load variant of the pass's render pass). Nothing is loaded + // from memory into the tile for them, which is also the cheapest form on a tiler. + // * Any other clear - into the open pass, or scissored - is vkCmdClearAttachments in the pass: + // ordered with the pass's draws as a draw is. + // + // Every write goes through the pass's attachment views (the UNORM twin of an sRGB image while + // GL_FRAMEBUFFER_SRGB is off, exactly GL's raw clear - no pre-compensation), and wire attachments + // live in GENERAL. What none of this expresses goes back to the standalone path, per GL buffer + // bit: a partial colour or stencil write mask (that path warns and skips, as before), an + // attachment whose extent or layer count differs from the pass's (the pass covers the common area + // only), and a stencil clear the pass's depth attachment does not carry. GLbitfield VulkanRenderer::ClearWireFramebufferInPass(const MG_Pipe::MGPFramebufferState& fbo, const ClearAttachmentPayload& payload, GLint drawbuffer) { GLbitfield remaining = payload.mask & (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); if (!remaining || MG_Config::Features.MagmaInPassClear == MG_Config::QuirkOverride::ForceOff) return remaining; // Nested inside a draw's preparation the scratch and the pass belong to that draw. if (m_wirePreparationDepth != 0) return remaining; + // NOT THE DEFAULT FRAMEBUFFER (measured, not understood): on Adreno 750 the openra retrace + // loses its palette-sampled sprites (SSIM 0.956) when a colour+depth clear of the default + // framebuffer becomes part of its draw pass - with LOAD_OP_CLEAR as well as with + // vkCmdClearAttachments - while the same clear split across the two paths, or left + // standalone, renders correctly. Default-framebuffer clears keep the standalone path until + // that is explained (perf-p15/HANDOFF.md). + if (fbo.IsDefault && MG_Config::Features.MagmaInPassClearDefault != MG_Config::QuirkOverride::ForceOn) return remaining; auto& frame = m_frameContext.GetCurrent(); WirePreparationScope preparing(m_wirePreparationDepth); // As SetupWireDraw: resolve every attachment (a lazy upload or promotion may submit the @@ -992,16 +1026,66 @@ const Int32 x0 = std::max(box.x(), 0), y0 = std::max(box.y(), 0); const Int32 x1 = std::min(box.x() + box.z(), static_cast(key.width)); const Int32 y1 = std::min(box.y() + box.w(), static_cast(key.height)); - if (x0 >= x1 || y0 >= y1) return remaining & ~(handled | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | - GL_STENCIL_BUFFER_BIT); // an empty box clears nothing + // An empty box clears nothing, on either path. + if (x0 >= x1 || y0 >= y1) return 0; area = {{x0, y0}, {static_cast(x1 - x0), static_cast(y1 - y0)}}; if (fbo.IsDefault && !MapDefaultFramebufferReadbackRect(x0, y0, x1 - x0, y1 - y0, VkExtent2D{key.width, key.height}, m_swapchainObject.GetPreTransform(), &area.offset, &area.extent)) MagmaWireFatal("clear-surface-transform"); } + const Bool wholeArea = area.offset.x == 0 && area.offset.y == 0 && area.extent.width == key.width && + area.extent.height == key.height; const auto* active = VkRenderPassManager::GetActiveRenderPass(); - const Bool open = continuing && active != nullptr && active->commandBuffer == frame.commandBuffer && - active->hash == pass.hash; + Bool open = continuing && active != nullptr && active->commandBuffer == frame.commandBuffer && + active->hash == pass.hash; + if (wholeArea && !open) { + if (active != nullptr) VkRenderPassManager::EndRenderPass(frame.commandBuffer); + auto& begun = *m_wireDrawPass; + Uint64 variantKey = 0; + VkClearValue values[MG_Pipe::kMGPipeMaxColorAttachments + 1]{}; + const Uint32 attachmentCount = static_cast(begun.wireAttachments.size()); + if (attachmentCount == 0 || attachmentCount > MG_Pipe::kMGPipeMaxColorAttachments + 1) + return remaining; // not a wire-built pass: cannot happen, but never guess + for (Uint32 c = 0; c < count; ++c) { + const auto& clear = clears[c]; + if (clear.aspectMask & VK_IMAGE_ASPECT_COLOR_BIT) { + const Uint32 a = begun.wireColors[clear.colorAttachment].attachment; + variantKey |= 1ull << (2 * a); + values[a].color = clear.clearValue.color; + } else { + const Uint32 a = begun.wireDepth.attachment; + if (clear.aspectMask & VK_IMAGE_ASPECT_DEPTH_BIT) variantKey |= 1ull << (2 * a); + if (clear.aspectMask & VK_IMAGE_ASPECT_STENCIL_BIT) variantKey |= 1ull << (2 * a + 1); + values[a].depthStencil = clear.clearValue.depthStencil; + } + } + VkRenderPass variant = VK_NULL_HANDLE; + for (const auto& entry : begun.clearVariants) + if (entry.first == variantKey) variant = entry.second; + if (variant == VK_NULL_HANDLE) { + auto descriptions = begun.wireAttachments; + for (Uint32 a = 0; a < attachmentCount; ++a) { + if (variantKey >> (2 * a) & 1) descriptions[a].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + if (variantKey >> (2 * a + 1) & 1) descriptions[a].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + } + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = static_cast(begun.wireColors.size()); + subpass.pColorAttachments = begun.wireColors.data(); + subpass.pDepthStencilAttachment = + begun.wireDepth.attachment != VK_ATTACHMENT_UNUSED ? &begun.wireDepth : nullptr; + VkRenderPassCreateInfo info{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + info.attachmentCount = attachmentCount; + info.pAttachments = descriptions.data(); + info.subpassCount = 1; + info.pSubpasses = &subpass; + VK_VERIFY(vkCreateRenderPass(m_device, &info, nullptr, &variant)); + begun.clearVariants.push_back({variantKey, variant}); + } + if (!VkRenderPassManager::BeginRenderPass(frame.commandBuffer, begun, variant, values, attachmentCount)) + return remaining; + return remaining & ~handled; + } if (!open && active != nullptr) VkRenderPassManager::EndRenderPass(frame.commandBuffer); if (!open && !VkRenderPassManager::BeginRenderPass(frame.commandBuffer, pass)) return remaining; const VkClearRect rect{area, 0, key.layers}; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 9be0e16d..8b65829e 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -98,6 +98,8 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearThenReadPixelsScenario.cpp # P15 S0: a clear between draws into the same framebuffer (DirectVulkan records it in the pass). Scenarios/InPassClearScenario.cpp + # P15 S0: a CPU-converted vertex stream reused and re-written within a frame (DirectVulkan caches it). + Scenarios/ConvertedVertexStreamScenario.cpp Scenarios/SampleVariablesScenario.cpp Scenarios/DepthStencilReadbackScenario.cpp Scenarios/DepthStencilReadbackMatrixScenario.cpp @@ -2455,6 +2457,7 @@ if (MOBILEGL_BUILD_DISAGGREGATED) mgl_itest_register_split_arms("ClearThenReadPixelsScenario.*" "") mgl_itest_register_split_arms("InPassClearScenario.*" "") + mgl_itest_register_split_arms("ConvertedVertexStreamScenario.*" "") # GL_ARB_shader_objects (P13): ARB-created shader / program objects travel the record arm like # core-created ones, so the scenario runs on every split arm as well as both monoliths. mgl_itest_register_split_arms("ArbShaderObjectsScenario.*" "") diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.cpp new file mode 100644 index 00000000..a77ec755 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.cpp @@ -0,0 +1,261 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.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 VERTEX STREAM THE BACKEND CONVERTS ON THE CPU, REUSED AND RE-WRITTEN WITHIN A FRAME. +// +// P15 S0: DirectVulkan's wire arm converts a vertex stream whose layout the device cannot fetch +// (here a 16-bit attribute at an odd stride, which it repacks) once per (buffer content, layout) +// and reuses the converted copy for every draw that names it, frame after frame - a chunk +// renderer's multi-draws all name one arena. What that cache must get right: +// +// * REUSE: two indexed draws in one frame over different base vertices of one converted stream +// both read their own vertices out of the shared conversion. +// * INVALIDATION: a glBufferSubData of the source between two draws in the SAME frame is seen by +// the second draw (the store's content serial moved, so the cached conversion is not reused). +// * ACROSS FRAMES: an unchanged stream drawn over more frames than there are frame slots keeps +// drawing right, and a glBufferSubData between frames is seen by the next frame's draw. +// +// DirectGLES is the control; MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE=0 converts per draw. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +layout(location = 0) in vec2 aPos; +void main() { gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +uniform vec4 uColor; +out vec4 o0; +void main() { o0 = uColor; } +)"; + + constexpr int kSize = 64; + // 5-byte stride: the 4-byte (2 x GL_UNSIGNED_SHORT) attribute sits at odd offsets, which + // no device fetches directly, so Magma repacks the stream on the CPU. + constexpr int kStride = 5; + + class ConvertedVertexStreamScenario : public ScenarioTest {}; + + // Writes vertex `index` of the interleaved stream: (x, y) in [0, 1] as normalized shorts. + void PutVertex(std::vector& bytes, int index, float x, float y) { + const unsigned short sx = static_cast(x * 65535.0f + 0.5f); + const unsigned short sy = static_cast(y * 65535.0f + 0.5f); + std::memcpy(bytes.data() + index * kStride, &sx, 2); + std::memcpy(bytes.data() + index * kStride + 2, &sy, 2); + } + + // Four vertices of an axis-aligned quad as a triangle strip. + void PutQuad(std::vector& bytes, int first, float x0, float y0, float x1, float y1) { + PutVertex(bytes, first + 0, x0, y0); + PutVertex(bytes, first + 1, x1, y0); + PutVertex(bytes, first + 2, x0, y1); + PutVertex(bytes, first + 3, x1, y1); + } + + ::testing::AssertionResult Near(const Rgba8& got, int r, int g, int b) { + const auto close = [](int v, int want) { return v >= want - 3 && v <= want + 3; }; + if (close(got.r, r) && close(got.g, g) && close(got.b, b)) return ::testing::AssertionSuccess(); + return ::testing::AssertionFailure() << "got (" << int(got.r) << ", " << int(got.g) << ", " << int(got.b) + << "), want (" << r << ", " << g << ", " << b << ")"; + } + + struct Target { + GLuint fbo = 0, color = 0; + }; + + Target MakeTarget() { + Target t; + glGenTextures(1, &t.color); + glBindTexture(GL_TEXTURE_2D, t.color); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize); + glGenFramebuffers(1, &t.fbo); + glBindFramebuffer(GL_FRAMEBUFFER, t.fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, t.color, 0); + glViewport(0, 0, kSize, kSize); + glDisable(GL_DEPTH_TEST); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_BLEND); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + return t; + } + + Rgba8 At(int x, int y) { + const Image image = ReadPixelsRect(x, y, 1, 1); + return image.At(0, 0); + } + + } // namespace + + TEST_F(ConvertedVertexStreamScenario, TwoIndexedDrawsShareOneConversionAndReadTheirOwnVertices) { + if (!Ready()) return; + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + Target target = MakeTarget(); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + std::vector bytes(8 * kStride, 0); + PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half + PutQuad(bytes, 4, 0.5f, 0.0f, 1.0f, 1.0f); // right half + const unsigned short indices[] = {0, 1, 2, 3}; + GLuint vao = 0, vbo = 0, ibo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, static_cast(bytes.size()), bytes.data(), GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr); + glGenBuffers(1, &ibo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); + + glUseProgram(program); + const GLint color = glGetUniformLocation(program, "uColor"); + glUniform4f(color, 1.0f, 0.0f, 0.0f, 1.0f); + glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr, 0); + glUniform4f(color, 0.0f, 1.0f, 0.0f, 1.0f); + glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr, 4); + EXPECT_EQ(FirstGLError(), 0u); + + EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, 0, 0)) << "left half: the first draw's vertices"; + EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 255, 0)) << "right half: base vertex 4 of the same stream"; + + glDeleteBuffers(1, &ibo); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &target.fbo); + glDeleteTextures(1, &target.color); + glDeleteProgram(program); + } + + TEST_F(ConvertedVertexStreamScenario, ASubDataBetweenTwoDrawsInOneFrameIsSeenByTheSecond) { + if (!Ready()) return; + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + Target target = MakeTarget(); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + std::vector bytes(4 * kStride, 0); + PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half + const unsigned short indices[] = {0, 1, 2, 3}; + GLuint vao = 0, vbo = 0, ibo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, static_cast(bytes.size()), bytes.data(), GL_DYNAMIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr); + glGenBuffers(1, &ibo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); + + glUseProgram(program); + const GLint color = glGetUniformLocation(program, "uColor"); + glUniform4f(color, 1.0f, 0.0f, 0.0f, 1.0f); + glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr); + // Same buffer, same layout, new bytes: the quad moves to the right half. + PutQuad(bytes, 0, 0.5f, 0.0f, 1.0f, 1.0f); + glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast(bytes.size()), bytes.data()); + glUniform4f(color, 0.0f, 0.0f, 1.0f, 1.0f); + glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr); + EXPECT_EQ(FirstGLError(), 0u); + + EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, 0, 0)) << "left half: the draw before the sub-data"; + EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 255)) + << "right half: the draw after the sub-data must read the new bytes, not the cached conversion"; + + glDeleteBuffers(1, &ibo); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &target.fbo); + glDeleteTextures(1, &target.color); + glDeleteProgram(program); + } + + TEST_F(ConvertedVertexStreamScenario, AConversionKeptAcrossFramesIsReplacedByASubDataBetweenFrames) { + if (!Ready()) return; + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + Target target = MakeTarget(); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + std::vector bytes(4 * kStride, 0); + PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half + GLuint vao = 0, vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, static_cast(bytes.size()), bytes.data(), GL_DYNAMIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr); + glUseProgram(program); + const GLint color = glGetUniformLocation(program, "uColor"); + + // Several frames - more than any frame-slot count - drawing the one unchanged stream: every + // frame after the first reuses the conversion the first one made. + for (int frame = 0; frame < 5; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glViewport(0, 0, kSize, kSize); + glClear(GL_COLOR_BUFFER_BIT); + glUniform4f(color, 1.0f, static_cast(frame) / 4.0f, 0.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, (frame * 255 + 2) / 4, 0)) << "frame " << frame; + EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 0)) << "frame " << frame; + Gl().EndFrame(); + } + // New bytes between frames: the next frame's draw must not reuse the kept conversion. + PutQuad(bytes, 0, 0.5f, 0.0f, 1.0f, 1.0f); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast(bytes.size()), bytes.data()); + for (int frame = 0; frame < 3; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glViewport(0, 0, kSize, kSize); + glClear(GL_COLOR_BUFFER_BIT); + glUniform4f(color, 0.0f, 0.0f, 1.0f, 1.0f); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 0, 0, 0)) + << "frame " << frame << " after the sub-data: the old conversion was reused"; + EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 255)) << "frame " << frame << " after the sub-data"; + Gl().EndFrame(); + } + EXPECT_EQ(FirstGLError(), 0u); + + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &target.fbo); + glDeleteTextures(1, &target.color); + glDeleteProgram(program); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/InPassClearScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/InPassClearScenario.cpp index 38b7d786..419ca7b0 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/InPassClearScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/InPassClearScenario.cpp @@ -292,4 +292,68 @@ void main() { o0 = uColor; o1 = uColor; } glDeleteTextures(1, &color); } + // The openra shape on the DEFAULT framebuffer (P15 S0's open item, perf-p15/HANDOFF.md): every + // frame starts with glClear(COLOR | DEPTH), then re-specifies a palette texture + // (glTexImage2D - a recording flush on Magma) before the first draw samples it, and the + // sprites are depth-tested. Two frames, so the clear lands on a freshly acquired image. + TEST_F(InPassClearScenario, ADefaultFramebufferClearThenAPaletteUploadKeepsTheDepthTestedDraw) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(), height = gl.Height(); + constexpr const char* kPaletteVS = R"(#version 330 core +in vec2 aPos; +uniform float uDepth; +void main() { gl_Position = vec4(aPos, uDepth, 1.0); } +)"; + constexpr const char* kPaletteFS = R"(#version 330 core +uniform sampler2D uPalette; +out vec4 o0; +void main() { o0 = texture(uPalette, vec2(0.5, 0.5)); } +)"; + std::string error; + const unsigned int paletteProgram = CompileProgram(kPaletteVS, kPaletteFS, &error); + ASSERT_NE(paletteProgram, 0u) << error; + Build(); + GLuint palette = 0; + glGenTextures(1, &palette); + for (int frame = 0; frame < 3; ++frame) { + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_BLEND); + glDepthMask(GL_TRUE); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClearDepth(1.0); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + // The palette changes every frame; this frame's colour is what the sprite must show. + const unsigned char texel[4] = {static_cast(frame == 2 ? 0 : 255), 255, 0, 255}; + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, palette); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, texel); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + // Terrain: no depth test, red. + glDisable(GL_DEPTH_TEST); + Quad(1.0f, 0.0f, 0.0f, 1.0f, 0.5f); + // Sprites: depth-tested against the cleared depth, palette-coloured. + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LEQUAL); + glUseProgram(paletteProgram); + glUniform1i(glGetUniformLocation(paletteProgram, "uPalette"), 0); + glUniform1f(glGetUniformLocation(paletteProgram, "uDepth"), 0.0f); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glDisable(GL_DEPTH_TEST); + EXPECT_EQ(FirstGLError(), 0u); + if (frame == 2) { + const Image image = ReadPixels(width, height); + EXPECT_TRUE(Near(image.At(width / 2, height / 2), 0, 255, 0, 255)) + << "frame 2: the depth-tested sprite shows this frame's palette (green)"; + } + gl.EndFrame(); + } + glDeleteTextures(1, &palette); + glDeleteProgram(paletteProgram); + } + } // namespace MGITest