From d0aae85da26d725ddbf9f7d67eefbeae1964c987 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Thu, 6 Aug 2026 12:00:31 -0400 Subject: [PATCH] [Perf] (MG_Backend): let DirectVulkan's draw fast path survive a VAO swap TrySetupDrawFastPath declined on its VAO pointer check for every draw of a 512-VAO cycle - the Blaze3D chunk-render shape - so the fast path was dead exactly where it mattered: full SetupDraw, per-draw ResolveSamplerDescriptor, SyncTextureAndGetDescriptor and render-pass re-fetch, for draws whose only change was the VAO. Three fixes. A moved VAO now re-runs only the vertex-input pre-flight and re-resolves the pipeline instead of declining to the full path. That resolution probes the value-keyed pipeline memo directly off a cached pipeline-state hash and snapshot render-pass hash, skipping GetOrCreateRenderPass and its GetPendingRenderbufferClear probes per draw; a stale cached hash can only miss, never false-hit. And when the sampler-descriptor hint holds and the program's single dynamic UBO re-resolves to the same VkBuffer and range - only the dynamic offset moved, the per-draw glUniform case - the descriptor walk collapses to one offset recompute and a vkCmdBindDescriptorSets of the same recorded set with new pDynamicOffsets. The rebind memo is invalidated at BeginFrame, layout destruction and override walks; the program lifetime id never repeats, and per-frame descriptor sets are never rewritten within their frame. mc_vanilla_draw -36.9% (1260 -> 795 ns/op, 4.6x native to 3.4x), sodium_multidraw -18.0%, state_toggle -14.9%, sampler_churn -7.8%, use_program -7.7%, ubo_range -7.3%, tex_param -7.3%. All nine cases on both backends, interleaved A/B; no attributable regression. Unit tests 421/421. --- .../DirectVulkan/Renderer/UniformManager.cpp | 234 +++++++++++------- .../DirectVulkan/Renderer/UniformManager.h | 46 ++++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 97 ++++++-- 3 files changed, 275 insertions(+), 102 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 827c244f..cf779082 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -207,6 +207,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { for (auto& entry : m_descriptorReuseMemo) { entry.valid = false; } + m_fastRebindMemo.valid = false; m_lastBindValid = false; // Re-fingerprint the bound sampler set fresh this frame so any GL object address // reuse cannot outlive a single frame (see SamplerResolveMemo). @@ -248,6 +249,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { for (auto& entry : m_descriptorReuseMemo) { entry.valid = false; } + // The rebind memo's set may be among the freed ones. + m_fastRebindMemo.valid = false; MGLOG_D("UniformDescriptorBinder: freed %zu descriptor sets for destroyed layout", purgedSets); } } @@ -1242,6 +1245,94 @@ namespace MobileGL::MG_Backend::DirectVulkan { return VK_SUCCESS; } + Bool UniformManager::ResolveDynamicUboDescriptor(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, + Uint32 binding, Uint32 arrayElement, Uint32 frameIndex, + VkBuffer& outBuffer, VkDeviceSize& outRange, + Uint32& outDynamicOffset) { + UboBindResult ubo{}; + const Bool hasPayload = ResolveUniformBufferPayload(program, programObj, binding, arrayElement, ubo); + MOBILEGL_ASSERT(hasPayload && (ubo.directBindable || (ubo.payload != nullptr && ubo.payloadSize > 0)), + "UniformDescriptorBinder::ResolveDynamicUboDescriptor failed: missing UBO payload on binding %u element %u", + binding, arrayElement); + if (ubo.directBindable) { + // Zero-copy: bind the app's resident VkBuffer directly, no per-draw memcpy. + outBuffer = ubo.buffer; + outRange = ubo.range; + outDynamicOffset = static_cast(ubo.dynamicOffset); + return true; + } + // Global-UBO slice reuse (see GlobalUboSliceMemo): unchanged + // uniform bytes re-use the slice already uploaded this frame. + const Bool isGlobalUbo = programObj.globalUboBinding == static_cast(binding) && arrayElement == 0; + const Uint64 uboFrameSerial = m_bufferManager->GetFrameSerial(); + const Uint64 uboProgramLifetimeId = program.GetLifetimeId(); + const Uint32 uboContentVersion = program.GetUBOContentVersion(); + if (isGlobalUbo) { + for (const auto& memo : m_globalUboMemo) { + if (memo.buffer != VK_NULL_HANDLE && memo.programLifetimeId == uboProgramLifetimeId && + memo.frameSerial == uboFrameSerial && memo.uboContentVersion == uboContentVersion && + memo.range == static_cast(ubo.payloadSize)) { + outBuffer = memo.buffer; + outRange = memo.range; + outDynamicOffset = static_cast(memo.offset); + return true; + } + } + } + BufferSlice slice{}; + if (!m_bufferManager->UploadTransient(BufferKind::Uniform, frameIndex, ubo.payload, ubo.payloadSize, + m_minDynamicOffsetAlignment, slice)) { + MOBILEGL_ASSERT(false, + "UniformDescriptorBinder::ResolveDynamicUboDescriptor failed: UBO upload failed on binding %u element %u", + binding, arrayElement); + return false; + } + outBuffer = slice.buffer; + outRange = ubo.payloadSize; + outDynamicOffset = static_cast(slice.offset); + if (isGlobalUbo) { + m_globalUboMemo[m_globalUboMemoNext] = + GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion, + slice.buffer, slice.offset, static_cast(ubo.payloadSize)}; + m_globalUboMemoNext = (m_globalUboMemoNext + 1) % kGlobalUboMemoSize; + } + return true; + } + + void UniformManager::BindDescriptorSetDeduped(VkCommandBuffer commandBuffer, VkPipelineBindPoint bindPoint, + VkPipelineLayout pipelineLayout, VkDescriptorSet descriptorSet, + const Vector& dynamicOffsets) { + // Skip the driver call when this exact binding is already live on the + // command buffer (see the bind-dedup shadow in the header). + const Uint32 offsetCount = static_cast(dynamicOffsets.size()); + Bool identicalBind = m_lastBindValid && m_lastBindSet == descriptorSet && + m_lastBindLayout == pipelineLayout && m_lastBindPoint == bindPoint && + m_lastBindOffsetCount == offsetCount && offsetCount <= kMaxShadowedDynamicOffsets; + if (identicalBind) { + for (Uint32 i = 0; i < offsetCount; ++i) { + if (m_lastBindOffsets[i] != dynamicOffsets[i]) { + identicalBind = false; + break; + } + } + } + if (!identicalBind) { + vkCmdBindDescriptorSets(commandBuffer, bindPoint, pipelineLayout, 0, 1, + &descriptorSet, offsetCount, dynamicOffsets.data()); + if (offsetCount <= kMaxShadowedDynamicOffsets) { + m_lastBindValid = true; + m_lastBindSet = descriptorSet; + m_lastBindLayout = pipelineLayout; + m_lastBindPoint = bindPoint; + m_lastBindOffsetCount = offsetCount; + std::copy_n(dynamicOffsets.data(), offsetCount, m_lastBindOffsets); + } else { + m_lastBindValid = false; + } + } + } + Bool UniformManager::BindProgramUniformBuffers(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, @@ -1258,6 +1349,34 @@ namespace MobileGL::MG_Backend::DirectVulkan { frame.activeDescriptorPoolIndex = 0; } + // Dynamic-offset-only rebind (see FastRebindMemo in the header): the last + // cacheable walk of this exact program selected a set whose contents are + // provably still what this walk would write - the hint covers every + // sampler binding, and an unchanged (buffer, range) for the single + // dynamic UBO covers the rest - except the dynamic offset, which rebinding + // the SAME set delivers without any descriptor write. + const Bool cacheable = (samplerBindingOverride == nullptr); + if (cacheable && samplerDescriptorsUnchangedHint && m_fastRebindMemo.valid && + m_fastRebindMemo.frameIndex == frameIndex && + m_fastRebindMemo.programLifetimeId == program.GetLifetimeId() && + m_fastRebindMemo.programHash == programObj.hash) { + VkBuffer uboBuffer = VK_NULL_HANDLE; + VkDeviceSize uboRange = 0; + Uint32 uboDynamicOffset = 0; + if (ResolveDynamicUboDescriptor(program, programObj, m_fastRebindMemo.uboBinding, 0, frameIndex, + uboBuffer, uboRange, uboDynamicOffset) && + uboBuffer == m_fastRebindMemo.uboBuffer && uboRange == m_fastRebindMemo.uboRange) { + auto& fastOffsets = m_dynamicOffsetsScratch; + fastOffsets.clear(); + fastOffsets.push_back(uboDynamicOffset); + BindDescriptorSetDeduped(commandBuffer, bindPoint, programObj.pipelineLayout, + m_fastRebindMemo.set, fastOffsets); + return true; + } + // Any mismatch (arena wrap or growth, direct-bind retarget, upload + // failure) falls through to the full walk, which re-records the memo. + } + // The descriptor set is chosen AFTER the writes are built (below), so a draw // whose resolved descriptor content matches the previous draw can reuse that // set and skip both AcquireDescriptorSet and vkUpdateDescriptorSets. @@ -1289,6 +1408,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { texelBufferViews.reserve(m_maxBindings); dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra); + // Eligibility probe for FastRebindMemo, filled by this walk: exactly one + // dynamic-UBO descriptor (no arrayed elements) and otherwise only + // combined-image samplers, so the whole set's content is pinned by the + // sampler hint plus one (buffer, range) compare. + Uint32 dynamicUboDescriptorCount = 0; + Uint32 fastRebindUboBinding = 0; + Bool fastRebindKindsEligible = true; + // Iterate only the bindings this program declares. The old walk covered all 256 slots of // bindingKinds on every draw to find the 1-8 a real program uses. for (const Uint32 binding : programObj.activeBindings) { @@ -1309,67 +1436,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { binding < programObj.bindingDescriptorCounts.size() ? std::max(1, programObj.bindingDescriptorCounts[binding]) : 1u; + dynamicUboDescriptorCount += descriptorCount; + fastRebindUboBinding = binding; const SizeT firstBufferInfoIndex = bufferInfos.size(); for (Uint32 element = 0; element < descriptorCount; ++element) { - UboBindResult ubo{}; - const Bool hasPayload = - ResolveUniformBufferPayload(program, programObj, binding, element, ubo); - MOBILEGL_ASSERT(hasPayload && ubo.payload != nullptr && ubo.payloadSize > 0, - "UniformDescriptorBinder::BindProgramUniformBuffers failed: missing UBO payload on binding %u element %u", - binding, element); - VkDescriptorBufferInfo bufferInfo{}; // Keep offset 0 (sub-range selected via the dynamic offset) so the hashed bufferInfo // is stable across draws and the descriptor-set reuse cache keeps hitting. bufferInfo.offset = 0; - Uint32 dynOffset; - if (ubo.directBindable) { - // Zero-copy: bind the app's resident VkBuffer directly, no per-draw memcpy. - bufferInfo.buffer = ubo.buffer; - bufferInfo.range = ubo.range; - dynOffset = static_cast(ubo.dynamicOffset); - } else { - // Global-UBO slice reuse (see GlobalUboSliceMemo): unchanged - // uniform bytes re-use the slice already uploaded this frame. - const Bool isGlobalUbo = - programObj.globalUboBinding == static_cast(binding) && element == 0; - const Uint64 uboFrameSerial = m_bufferManager->GetFrameSerial(); - const Uint64 uboProgramLifetimeId = program.GetLifetimeId(); - const Uint32 uboContentVersion = program.GetUBOContentVersion(); - Bool reusedSlice = false; - if (isGlobalUbo) { - for (const auto& memo : m_globalUboMemo) { - if (memo.buffer != VK_NULL_HANDLE && - memo.programLifetimeId == uboProgramLifetimeId && - memo.frameSerial == uboFrameSerial && - memo.uboContentVersion == uboContentVersion && - memo.range == static_cast(ubo.payloadSize)) { - bufferInfo.buffer = memo.buffer; - bufferInfo.range = memo.range; - dynOffset = static_cast(memo.offset); - reusedSlice = true; - break; - } - } - } - if (!reusedSlice) { - BufferSlice slice{}; - if (!m_bufferManager->UploadTransient(BufferKind::Uniform, frameIndex, ubo.payload, - ubo.payloadSize, m_minDynamicOffsetAlignment, slice)) { - MOBILEGL_ASSERT(false, "UniformDescriptorBinder::BindProgramUniformBuffers failed: UBO upload failed on binding %u element %u", - binding, element); - return false; - } - bufferInfo.buffer = slice.buffer; - bufferInfo.range = ubo.payloadSize; - dynOffset = static_cast(slice.offset); - if (isGlobalUbo) { - m_globalUboMemo[m_globalUboMemoNext] = GlobalUboSliceMemo{ - uboProgramLifetimeId, uboFrameSerial, uboContentVersion, - slice.buffer, slice.offset, static_cast(ubo.payloadSize)}; - m_globalUboMemoNext = (m_globalUboMemoNext + 1) % kGlobalUboMemoSize; - } - } + Uint32 dynOffset = 0; + if (!ResolveDynamicUboDescriptor(program, programObj, binding, element, frameIndex, + bufferInfo.buffer, bufferInfo.range, dynOffset)) { + return false; } bufferInfos.push_back(bufferInfo); // Dynamic offsets are consumed in binding order, then array element order, @@ -1392,6 +1470,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } texelBufferViews.push_back(bufferView); + fastRebindKindsEligible = false; write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER; write.pTexelBufferView = &texelBufferViews.back(); writes.push_back(write); @@ -1405,6 +1484,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } bufferInfos.push_back(bufferInfo); + fastRebindKindsEligible = false; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; write.pBufferInfo = &bufferInfos.back(); writes.push_back(write); @@ -1417,6 +1497,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } imageInfos.push_back(imageInfo); + fastRebindKindsEligible = false; write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; write.pImageInfo = &imageInfos.back(); writes.push_back(write); @@ -1461,7 +1542,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { // cursor only advances), so its written contents survive; the layout is part of // the signature so reuse never crosses programs. Sampler overrides (blits) // bypass and invalidate the cache. - const Bool cacheable = (samplerBindingOverride == nullptr); Uint64 signature = 0xcbf29ce484222325ULL; { const auto mix64 = [&signature](Uint64 word) { @@ -1524,34 +1604,22 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } - // Skip the driver call when this exact binding is already live on the - // command buffer (see the bind-dedup shadow in the header). - const Uint32 offsetCount = static_cast(dynamicOffsets.size()); - Bool identicalBind = m_lastBindValid && m_lastBindSet == descriptorSet && - m_lastBindLayout == programObj.pipelineLayout && m_lastBindPoint == bindPoint && - m_lastBindOffsetCount == offsetCount && offsetCount <= kMaxShadowedDynamicOffsets; - if (identicalBind) { - for (Uint32 i = 0; i < offsetCount; ++i) { - if (m_lastBindOffsets[i] != dynamicOffsets[i]) { - identicalBind = false; - break; - } - } - } - if (!identicalBind) { - vkCmdBindDescriptorSets(commandBuffer, bindPoint, programObj.pipelineLayout, 0, 1, - &descriptorSet, offsetCount, dynamicOffsets.data()); - if (offsetCount <= kMaxShadowedDynamicOffsets) { - m_lastBindValid = true; - m_lastBindSet = descriptorSet; - m_lastBindLayout = programObj.pipelineLayout; - m_lastBindPoint = bindPoint; - m_lastBindOffsetCount = offsetCount; - std::copy_n(dynamicOffsets.data(), offsetCount, m_lastBindOffsets); - } else { - m_lastBindValid = false; - } + // (Re)record the dynamic-offset-only rebind memo. Recording on every + // cacheable walk (allocated or reused set alike - both hold exactly the + // content just computed) keeps the single slot tracking the most recent + // program; a non-cacheable override walk drops it alongside the reuse + // memo above. + if (cacheable && fastRebindKindsEligible && dynamicUboDescriptorCount == 1) { + m_fastRebindMemo = FastRebindMemo{ + /*valid=*/true, frameIndex, program.GetLifetimeId(), programObj.hash, + fastRebindUboBinding, bufferInfos[0].buffer, + bufferInfos[0].range, descriptorSet}; + } else { + m_fastRebindMemo.valid = false; } + + BindDescriptorSetDeduped(commandBuffer, bindPoint, programObj.pipelineLayout, descriptorSet, + dynamicOffsets); return true; } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index d11b444f..c3f30707 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -186,6 +186,21 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool ResolveUniformBufferPayload(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 arrayElement, UboBindResult& out) const; + // Shared resolution of one dynamic-UBO binding element into the + // (buffer, range, dynamicOffset) triple the descriptor consumes: direct + // bind, global-slice reuse, or transient upload. Used by the full walk + // and by the dynamic-offset-only rebind (see FastRebindMemo). + Bool ResolveDynamicUboDescriptor(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, Uint32 binding, + Uint32 arrayElement, Uint32 frameIndex, VkBuffer& outBuffer, + VkDeviceSize& outRange, Uint32& outDynamicOffset); + // The vkCmdBindDescriptorSets tail shared by the full walk and the + // dynamic-offset-only rebind: skips the driver call when this exact + // binding is already live on the command buffer (see the bind-dedup + // shadow below), otherwise binds and refreshes the shadow. + void BindDescriptorSetDeduped(VkCommandBuffer commandBuffer, VkPipelineBindPoint bindPoint, + VkPipelineLayout pipelineLayout, VkDescriptorSet descriptorSet, + const Vector& dynamicOffsets); Bool CreateDescriptorPool(Uint32 maxSets, VkDescriptorPool& outPool) const; Bool GrowFrameDescriptorPool(FrameResources& frame, Uint32 frameIndex); VkResult AllocateDescriptorSetsFromActivePool( @@ -233,6 +248,37 @@ namespace MobileGL::MG_Backend::DirectVulkan { DescriptorReuseEntry m_descriptorReuseMemo[kDescriptorReuseMemoSize]; Uint32 m_descriptorReuseMemoNext = 0; + // Dynamic-offset-only rebind (see BindProgramUniformBuffers): records the + // descriptor set selected by the last cacheable full walk of a program + // whose active bindings are exactly one dynamic UBO (single descriptor) + // plus combined-image samplers. When the next call proves every sampler + // descriptor input unchanged (samplerDescriptorsUnchangedHint) and the + // UBO re-resolves to the SAME VkBuffer+range - only the dynamic offset + // moved, the per-draw glUniform case - the walk collapses to: resolve one + // offset, rebind the recorded set with new pDynamicOffsets (Vulkan allows + // rebinding the same set with different dynamic offsets). + // Invalidation inventory: BeginFrame clears it (the frame's sets are + // recycled) and the frameIndex field guards cross-frame confusion on top; + // OnDescriptorSetLayoutDestroyed clears it (the set may be freed); a + // sampler-override walk clears it (mirrors m_descriptorReuseMemo); a + // program relink bumps the backend state version and thus programObj.hash + // so the key misses; the program lifetime id is never reused, so a + // deleted-and-recreated program misses; a texture/sampler/binding change + // drops the hint upstream; an arena wrap or growth resolves a different + // VkBuffer and misses. AcquireDescriptorSet's per-frame cursor only + // advances, so the recorded set is never re-written within its frame. + struct FastRebindMemo { + Bool valid = false; + Uint32 frameIndex = 0; + Uint64 programLifetimeId = 0; + ProgramFactory::HashType programHash = 0; + Uint32 uboBinding = 0; + VkBuffer uboBuffer = VK_NULL_HANDLE; + VkDeviceSize uboRange = 0; + VkDescriptorSet set = VK_NULL_HANDLE; + }; + FastRebindMemo m_fastRebindMemo; + // vkCmdBindDescriptorSets dedup: consecutive draws with a static uniform // block resolve to the same set AND the same dynamic offsets, so the // driver call can be skipped outright. Command-buffer-scope state; reset diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b86e626f..973c553d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -4916,10 +4916,15 @@ void main() { program.GetBackendStateVersion() != snap.programVersion) { return false; } + // A changed VAO does NOT decline: the VAO only feeds the pipeline's vertex + // input state (re-resolved below through the layout-keyed memo, so N VAOs + // sharing one attribute layout share one pipeline) and the vertex/index + // buffer binds (re-run every draw anyway). Declining here would send every + // draw of a VAO-cycling stream (Minecraft chunk rendering) through the full + // path, re-resolving descriptors and texture layouts nothing invalidated. const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); - if (static_cast(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion) { - return false; - } + const Bool vaoMoved = + static_cast(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion; const auto& drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (static_cast(drawFbo.get()) != snap.drawFbo || @@ -4956,6 +4961,30 @@ void main() { } const auto& programObj = m_programFactory->GetOrCreateProgram( program, ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags)); + if (vaoMoved) { + // Vertex-input pre-flight for the changed VAO, mirroring the full path: + // a bad attribute must never be baked into a cached VkPipeline, and the + // current-value synthesis in UploadAndBindVertexBuffers must never see + // an unsupported generic-attribute type. Declining routes the draw + // through the full path's loud failure reporting. + const auto& vertexInputState = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao); + const Uint32 activeAttribMask = programObj.activeVertexInputLocationMask; + if ((vertexInputState.unsupportedAttribMask & activeAttribMask) != 0) { + return false; + } + const Uint32 missingAttribMask = activeAttribMask & ~vertexInputState.attributeLocationMask; + if (missingAttribMask != 0) { + for (Uint32 location = 0; location < kMaxVertexAttribs; ++location) { + if ((missingAttribMask & (1u << location)) == 0) { + continue; + } + if (MG_State::GLState::ClassifyVertexAttribType(programObj.vertexInputTypes[location]) + .baseType == MG_State::GLState::VertexAttribBaseType::Unsupported) { + return false; + } + } + } + } if (bindsMoved && !m_uniformManager->SampledBindingsUnchanged(program, programObj, m_sampledBindingRecordsScratch)) { return false; @@ -5014,31 +5043,61 @@ void main() { } // Everything the full path would re-resolve is provably unchanged - or, for - // a moved pipeline-state version, reduces to re-resolving just the pipeline - // through the value-keyed memo against the still-active render pass. Run - // only the per-draw tail. + // a moved pipeline-state version or a changed VAO, reduces to re-resolving + // just the pipeline through the value-keyed memo against the still-active + // render pass. Run only the per-draw tail. VkPipeline pipeline = snap.pipeline; - if (renderStateMoved) { - // Same lookup the full path would do; every input (FBO + version, image - // index, depth/stencil participation, image epochs, no pending clears) - // was verified unchanged above, so this is a pure cache hit on the same - // entry the snapshot's pipeline was built against. - const RenderPassEntry& renderPassEntry = m_renderPassManager->GetOrCreateRenderPass( - *drawFbo, m_imageIndexAcquired, snap.drawUsesDepthStencil); - if (!activeRenderPass->CompatibleWith(renderPassEntry)) { - return false; + if (renderStateMoved || vaoMoved) { + pipeline = VK_NULL_HANDLE; + if (!renderStateMoved && m_pipelineStateHashValid && + m_pipelineStateHashVersion == renderStateVersion) { + // VAO-only movement: the render pass is provably the snapshot's (hash + // match above) and the pipeline-state hash is cached for this + // untouched state version, so probe the value-keyed pipeline memo + // directly - no render-pass-entry re-fetch (whose pending-clear + // probes cost more than the whole probe below). A miss, or a cached + // hash computed against another pass's attachment count (the hash + // folds it in, so such a mismatch can only produce a miss, never a + // false hit), falls through to the full lookup. + const auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao); + const auto memoTransformFlags = + ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags); + for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { + const PipelineMemoEntry& entry = m_pipelineMemo[i]; + if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && + entry.programHash == programObj.hash && entry.vertexInputHash == vis.layoutHash && + entry.renderPassHash == snap.renderPassHash && + entry.pipelineStateHash == m_pipelineStateHash && + entry.transformFlags == memoTransformFlags) { + pipeline = entry.pipeline; + break; + } + } } - pipeline = GetOrCreatePipeline(mode, program, programObj, - ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags), - vao, renderPassEntry); if (pipeline == VK_NULL_HANDLE) { - return false; + // Same lookup the full path would do; every input (FBO + version, image + // index, depth/stencil participation, image epochs, no pending clears) + // was verified unchanged above, so this is a pure cache hit on the same + // entry the snapshot's pipeline was built against. + const RenderPassEntry& renderPassEntry = m_renderPassManager->GetOrCreateRenderPass( + *drawFbo, m_imageIndexAcquired, snap.drawUsesDepthStencil); + if (!activeRenderPass->CompatibleWith(renderPassEntry)) { + return false; + } + pipeline = GetOrCreatePipeline(mode, program, programObj, + ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags), + vao, renderPassEntry); + if (pipeline == VK_NULL_HANDLE) { + return false; + } } } // Every decline is behind us: the snapshot again describes the current // counters, so the next draw's compare is two integer loads. snap.renderStateVersion = renderStateVersion; snap.bindGeneration = bindGeneration; + snap.vao = static_cast(&vao); + snap.vaoConfigVersion = vao.GetConfigVersion(); snap.pipeline = pipeline; if (!g_dynamicStateShadow.graphicsPipelineValid || g_dynamicStateShadow.graphicsPipeline != pipeline) {