From 25a8f51db55d2ea7c25cd2f428ef8a6ea3564d3b Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Thu, 6 Aug 2026 14:07:49 -0400 Subject: [PATCH] [Perf] (MG_Backend): make DirectGLES program switches remember their own bindings mc_use_program cycles programs whose texture bindings never change, yet every switch re-walked the units. Six fixes, one theme: a switch back to a known program should find its own state waiting. Per-program 4-entry resolved-texture-binding memo (round-robin, shadow memcmp on hit) skips the unit walk when a program returns with its bindings intact. The whole sampler-uniform pass in BindCurrentProgramWithResources is memoized per program twin behind (context, unitBindingsEpoch, samplingGeneration, backendStateVersion, textureContextGeneration) plus a per-sampled-unit sampler-shadow row compare, invalidated on relink/backend rebuild; the BindCurrentUnitSamplers walk sits behind the same keys. Every unit assignment, sampler-parameter change and bind path was verified to bump one of those inputs. UboRingAllocate's common path is now a generation check, a power-of-two mask, an overrun check and a head bump - the duplicate availability probe, frame-mark retirement and divisions moved to the wrap slow path. The per-context framebuffer binding slots (the frontend getter linear-scans per call) are cached as direct pointers - slots are by-value members of GLContext, so the pointers are stable by construction - feeding SyncCurrentFBO, SyncNeccessaryTextures and the broadcast memo; BindCurrentFBO's per-draw registry hash Find became a TwinLookupMemo probe. The VAO config-version cold-line load is hoisted to the top of PrepareForDraw to overlap its miss. Quiet-box load-gated 6-round order-alternating A/B, all nine cases, both backends: use_program -27.7%, vanilla_draw -16.2%, ubo_range -13.4%, pass_switch -11.1%, sampler_churn -10.7%, state_toggle -9.2%, sodium_multidraw -5.3%, rest flat. No regression on either backend (magma's one matrix flag disproved by isolated re-runs against byte-identical DirectVulkan sources). Unit tests 421/421. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 321 +++++++++++++----- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 52 ++- MobileGL/MG_Backend/DirectGLES/Managers.h | 43 +++ 3 files changed, 332 insertions(+), 84 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 47790d7e..6d4f5818 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -119,6 +119,36 @@ namespace MobileGL::MG_Backend::DirectGLES { g_vaoTwinLookupMemo; static TwinLookupMemo g_programTwinLookupMemo; + // Framebuffers qualify for the same memo (see the class comment's registry + // invariants: g_backendFramebufferObjects only GetOrCreate-resets an expired + // owner's twin and never erases/replaces a live one). Apps bind a handful of + // FBOs; 64 slots is plenty. + static TwinLookupMemo + g_fboTwinLookupMemo; + + // Cached addresses of the frontend's framebuffer binding slots. The frontend + // getter linear-scans its slot array per call and the draw path asks for these + // slots several times per draw (FBO sync, FBO texture-attachment sync, the + // draw-buffer broadcast memo, the draw-time bind). The slots are by-value + // members of the GLContext, so their addresses are a pure function of the + // context's address: the cache is keyed on the raw context pointer, and a + // recreated context landing on the SAME address has its slots at the same + // addresses again - the cached pointers cannot go stale. Invalidation is + // exactly the pointer compare below. + using FbBindingSlot = + std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; + static const MG_State::GLState::GLContext* g_fbSlotCacheContext = nullptr; + static Array g_fbSlotCache = {}; + static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) { + MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get(); + if (ctx != g_fbSlotCacheContext) { + for (SizeT i = 0; i < g_fbSlotCache.size(); ++i) { + g_fbSlotCache[i] = &ctx->GetFramebufferBindingSlot(static_cast(i)); + } + g_fbSlotCacheContext = ctx; + } + return *g_fbSlotCache[SizeT(target)]; + } static Bool IsDualSourceBlendFactor(BlendFactor v) { switch (v) { @@ -390,9 +420,15 @@ namespace MobileGL::MG_Backend::DirectGLES { BindBufferId(glTarget, backendResource->id); } + // `vaoConfigVersion` is the caller's early read of currentVAOObject->GetConfigVersion(): + // the VAO's config fields live on a cache line the draw path touches nowhere else, and + // cycling section VAOs makes that a guaranteed miss - reading it at the top of + // PrepareForDraw overlaps the miss with the program/texture-key work instead of + // stalling the memo check below. Nothing between the read and here can move it + // (only frontend GL entry points mutate VAO config, none run inside a preparation). void SyncNeccessaryBuffers(const SharedPtr& currentVAOObject, - VertexArrayImpl::BackendVertexArrayObject* vaoTwin, Bool includeIBO = false, - Bool includeIndirectBuffer = false) { + VertexArrayImpl::BackendVertexArrayObject* vaoTwin, Uint32 vaoConfigVersion, + Bool includeIBO = false, Bool includeIndirectBuffer = false) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -422,7 +458,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // read, makes the stamp stale, and re-runs the probes next draw. const Uint64 bufferEpoch = CurrentBufferMutationEpoch(); auto* memo = vaoTwin ? &vaoTwin->GetResolvedDrawBuffersMemo() : nullptr; - const Uint32 configVersion = currentVAOObject->GetConfigVersion(); + const Uint32 configVersion = vaoConfigVersion; if (memo && memo->valid && memo->configVersion == configVersion) { if (memo->vboCleanEpoch != bufferEpoch) { Bool allClean = true; @@ -1233,7 +1269,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // registry keeps a backend object alive until its frontend texture expires, which an // attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame - // reduces to the key compare and an empty loop. - const auto& drawSlot = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); const auto& currentFBO = drawSlot.GetBoundObject(); if (currentFBO) { const Uint16 fboSlotVersion = drawSlot.GetVersion(); @@ -1341,7 +1377,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr; for (auto& target : fboTargets) { - auto& slot = MG_State::pGLContext->GetFramebufferBindingSlot(target); + auto& slot = GetFramebufferBindingSlotFast(target); auto& currentFBO = slot.GetBoundObject(); // The three memos together say "this target is already synced": which object is @@ -1901,7 +1937,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // only runs later in PrepareForDraw: a program compiled against a stale count // would not be relinked until the draw after the one that needed it. { - const auto& drawSlot = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); const auto& drawFBO = drawSlot.GetBoundObject(); const Uint16 slotVersion = drawSlot.GetVersion(); const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0; @@ -1955,7 +1991,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = MG_State::pGLContext->GetFramebufferBindingSlot(target); + auto& slot = GetFramebufferBindingSlotFast(target); // No fast path on the binding slot's version. It is a 16-bit counter that only // ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old // snapshot and any later slot version that happened to land on it - one wrap of the @@ -1965,12 +2001,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // // Skipping the work is BindFramebufferId's job anyway: it shadows the driver's own // draw/read bindings and drops the glBindFramebuffer when the target already holds the id, - // which is where the cost actually is. What is left here is one registry lookup. + // which is where the cost actually is. What is left here is one registry lookup, + // and the twin memo replaces even that with an array probe on the steady path. const auto& currentFBO = slot.GetBoundObject(); if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { - auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (backendFBOSlot && *backendFBOSlot) { - (*backendFBOSlot)->Bind(target); + FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO); + if (!twin) { + auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (backendFBOSlot && *backendFBOSlot) { + twin = backendFBOSlot->get(); + g_fboTwinLookupMemo.Store(currentFBO, twin); + } + } + if (twin) { + twin->Bind(target); } else { MGLOG_E("No backend FBO found (maybe not synced) for current %s FBO, cannot bind FBO.", (target == FramebufferTarget::Read ? "READ" : "DRAW")); @@ -2013,7 +2057,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = MG_State::pGLContext->GetFramebufferBindingSlot(target); + auto& slot = GetFramebufferBindingSlotFast(target); const auto& fbo = slot.GetBoundObject(); SyncAndBindFramebufferObject(fbo, target); FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion(); @@ -2022,7 +2066,8 @@ namespace MobileGL::MG_Backend::DirectGLES { } static void BindCurrentProgramWithResources( - const SharedPtr& currentProgram); + const SharedPtr& currentProgram, + const TextureImpl::DrawTextureSyncKeys& keys); static void BindCurrentTextures(const TextureImpl::DrawTextureSyncKeys& keys, const SharedPtr& currentProgram); @@ -2037,6 +2082,9 @@ namespace MobileGL::MG_Backend::DirectGLES { const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); VertexArrayImpl::BackendVertexArrayObject* vaoTwin = currentVAO ? VertexArrayImpl::ResolveVaoTwin(currentVAO) : nullptr; + // Early config-version read: see the note on SyncNeccessaryBuffers - issuing + // the (cold-line) load here overlaps its miss with the resolves below. + const Uint32 vaoConfigVersion = currentVAO ? currentVAO->GetConfigVersion() : 0; // One program resolve and one texture-key capture serve the whole draw, for // the same reason the twin resolve does: only frontend GL entry points move // either, and none can run inside this preparation. GetProgramForDraw is a @@ -2045,7 +2093,8 @@ namespace MobileGL::MG_Backend::DirectGLES { const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); - BufferImpl::SyncNeccessaryBuffers(currentVAO, vaoTwin, syncBit & DrawSyncBit::IndexBuffer, + BufferImpl::SyncNeccessaryBuffers(currentVAO, vaoTwin, vaoConfigVersion, + syncBit & DrawSyncBit::IndexBuffer, syncBit & DrawSyncBit::IndirectBuffer); VertexArrayImpl::SyncCurrentVAO(currentVAO, vaoTwin); TextureImpl::SyncNeccessaryTextures(textureKeys); @@ -2069,7 +2118,7 @@ namespace MobileGL::MG_Backend::DirectGLES { VertexArrayImpl::SyncCurrentVertexAttributeValues(vaoTwin, currentProgram); BindCurrentTextures(textureKeys, currentProgram); - BindCurrentProgramWithResources(currentProgram); + BindCurrentProgramWithResources(currentProgram, textureKeys); // Last: opening the capture span needs the program current and the capture // buffers bound, and ES rejects most binding changes once it is open. @@ -2234,13 +2283,40 @@ namespace MobileGL::MG_Backend::DirectGLES { return nullptr; } - // Puts each touched unit's frontend sampler object on the backend unit. Deliberately - // NOT part of the memo below: BindCurrentProgramWithResources rewrites the sampler of - // every unit its program samples right after this runs (raw-depth-fetch substitution, - // per-program sampler objects), so the sampler shadow this leaves behind is not what - // the next call would find - a memo keyed on it could never hit. The step is a pointer - // compare per unit in the common case, far below the resolution cost the memo removes. - static void BindCurrentUnitSamplers(Int maxTouchedUnit) { + // Puts each touched unit's frontend sampler object on the backend unit. Not part + // of the texture memo below (BindCurrentProgramWithResources may rewrite sampled + // units' samplers right after this runs - raw-depth-fetch substitution, + // per-program sampler objects); instead the walk carries its own memo: + // + // The walk is a pure function of WHICH sampler object each touched unit holds - + // covered by (context id, unit-bindings epoch, high-water mark; the epoch + // snapshot includes each unit's sampler object and bumps when the mark moves) - + // and replaying it as "do nothing" additionally requires the sampler bindings it + // left to still be on the driver, which the row compare against + // g_boundSamplersCache (the shadow every sampler bind routes through) proves. + // Any later writer - the program pass substituting a raw-depth sampler, a + // frontend sampler bind/unbind (epoch), the ES-context generation moving - lands + // in one of those inputs. A unit whose sampler had no backend object yet leaves + // its row untouched; the object is only created by the program pass, whose Bind + // moves the row and thereby re-opens this memo, so the miss cannot be latched. + static Uint64 g_unitSamplerWalkContextId = 0; + static Uint64 g_unitSamplerWalkEpoch = 0; + static Int g_unitSamplerWalkMaxUnit = -1; + static Uint g_unitSamplerWalkContextGeneration = 0; + static Bool g_unitSamplerWalkValid = false; + static decltype(SamplerImpl::g_boundSamplersCache) g_unitSamplerWalkRows{}; + + static void BindCurrentUnitSamplers(const TextureImpl::DrawTextureSyncKeys& keys) { + const Int maxTouchedUnit = keys.maxTouchedUnit; + const SizeT rowBytes = + static_cast(maxTouchedUnit + 1) * sizeof(SamplerImpl::g_boundSamplersCache[0]); + if (g_unitSamplerWalkValid && g_unitSamplerWalkContextId == keys.contextId && + g_unitSamplerWalkEpoch == keys.unitBindingsEpoch && g_unitSamplerWalkMaxUnit == maxTouchedUnit && + g_unitSamplerWalkContextGeneration == TextureImpl::g_textureContextGeneration && + std::memcmp(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes) == 0) { + return; + } + g_unitSamplerWalkValid = false; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); if (samplerObject) { @@ -2254,6 +2330,12 @@ namespace MobileGL::MG_Backend::DirectGLES { SamplerImpl::UnbindSampler(unit); } } + g_unitSamplerWalkContextId = keys.contextId; + g_unitSamplerWalkEpoch = keys.unitBindingsEpoch; + g_unitSamplerWalkMaxUnit = maxTouchedUnit; + g_unitSamplerWalkContextGeneration = TextureImpl::g_textureContextGeneration; + std::memcpy(g_unitSamplerWalkRows.data(), SamplerImpl::g_boundSamplersCache.data(), rowBytes); + g_unitSamplerWalkValid = true; } // Memo of the resolved per-unit TEXTURE bindings, so a steady-state draw loop stops @@ -2305,7 +2387,16 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint contextGeneration = 0; decltype(TextureImpl::g_boundTexturesCache) boundTextures{}; }; - static ResolvedTextureBindingMemo g_resolvedTextureBindingMemo; + // Small per-PROGRAM memo set, not one global: the program is part of the key + // (it arbitrates aliased native targets), so a frame that cycles a handful of + // programs over UNCHANGED unit bindings - Sodium issues 62 glUseProgram per + // frame - invalidated a single memo on every switch and re-ran the full + // three-pass slot walk for an answer that had not moved. Entries are selected + // by raw program pointer; everything a recycled address could confuse is + // caught by the full key compare (lifetime id) exactly as before. Round-robin + // eviction; 4 entries cover the shader set a Minecraft-shaped frame cycles. + static Array g_resolvedTextureBindingMemos; + static Uint32 g_resolvedTextureBindingMemoCursor = 0; // Rebinds every frontend texture unit's textures (and sampler objects) on the // backend context. Needed before draws AND compute dispatches: content syncs @@ -2322,7 +2413,24 @@ namespace MobileGL::MG_Backend::DirectGLES { // Units past the frontend's high-water mark have provably-empty slots. const Int maxTouchedUnit = keys.maxTouchedUnit; - auto& memo = g_resolvedTextureBindingMemo; + // Entry selection by program pointer; a missing program takes the round-robin + // victim. WHICH entry is used is only a performance choice - correctness sits + // entirely in the full key + shadow compare below, unchanged from the single + // memo this set replaces. + const void* programKey = static_cast(currentProgram.get()); + ResolvedTextureBindingMemo* memoSlot = nullptr; + for (auto& candidate : g_resolvedTextureBindingMemos) { + if (candidate.valid && candidate.program == programKey) { + memoSlot = &candidate; + break; + } + } + if (!memoSlot) { + g_resolvedTextureBindingMemoCursor = + (g_resolvedTextureBindingMemoCursor + 1) % g_resolvedTextureBindingMemos.size(); + memoSlot = &g_resolvedTextureBindingMemos[g_resolvedTextureBindingMemoCursor]; + } + auto& memo = *memoSlot; const SizeT shadowBytes = static_cast(maxTouchedUnit + 1) * sizeof(TextureImpl::g_boundTexturesCache[0]); const Uint64 unitBindingsEpoch = keys.unitBindingsEpoch; @@ -2330,7 +2438,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.maxTouchedUnit == maxTouchedUnit && memo.unitBindingsEpoch == unitBindingsEpoch && memo.samplingResolutionGeneration == keys.samplingGeneration && - memo.program == static_cast(currentProgram.get()) && + memo.program == programKey && memo.programLifetimeId == (currentProgram ? currentProgram->GetLifetimeId() : 0) && memo.programBackendStateVersion == (currentProgram ? currentProgram->GetBackendStateVersion() : 0) && @@ -2346,7 +2454,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.maxTouchedUnit = maxTouchedUnit; memo.unitBindingsEpoch = unitBindingsEpoch; memo.samplingResolutionGeneration = keys.samplingGeneration; - memo.program = currentProgram.get(); + memo.program = programKey; memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0; memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); @@ -2356,7 +2464,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - BindCurrentUnitSamplers(maxTouchedUnit); + BindCurrentUnitSamplers(keys); } void BindCurrentTextures() { @@ -2370,7 +2478,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // this as much as draws do — e.g. Flywheel's cull shader reads the // _FlwFrameUniforms block and the _flw_depthPyramid sampler. static void BindCurrentProgramWithResources( - const SharedPtr& currentProgram) { + const SharedPtr& currentProgram, + const TextureImpl::DrawTextureSyncKeys& keys) { if (currentProgram && currentProgram->GetLinkStatus()) { #ifdef TRACY_ENABLE ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND); @@ -2512,65 +2621,119 @@ namespace MobileGL::MG_Backend::DirectGLES { // Sampler unit binding: backend locations are cached at link time; // glUniform1i is program state, so it is only re-issued when the // frontend-assigned unit differs from what this program last saw. - for (auto& samplerBinding : backendProgram.GetSamplerUniformBindings()) { - const auto unit = - currentProgram->GetUniformSamplerOrImageUnitIndex(samplerBinding.frontendLocation); - if (unit == -1) continue; - if (samplerBinding.lastAssignedUnit != unit) { - g_GLESFuncs.glUniform1i(samplerBinding.backendLocation, unit); - samplerBinding.lastAssignedUnit = unit; + // + // The whole pass sits behind the per-twin SamplerPassMemo (see its + // declaration for the invalidation enumeration): while the keys hold + // and every previously touched unit's sampler-shadow row is exactly + // what this pass last left there, re-running it is a provable no-op. + auto& samplerPassMemo = backendProgram.GetSamplerPassMemo(); + const Uint32 programBackendStateVersion = currentProgram->GetBackendStateVersion(); + Bool samplerPassClean = + samplerPassMemo.valid && samplerPassMemo.contextId == keys.contextId && + samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch && + samplerPassMemo.samplingGeneration == keys.samplingGeneration && + samplerPassMemo.backendStateVersion == programBackendStateVersion && + samplerPassMemo.textureContextGeneration == TextureImpl::g_textureContextGeneration; + if (samplerPassClean) { + for (Uint i = 0; i < samplerPassMemo.count; ++i) { + if (SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]] != + samplerPassMemo.rows[i]) { + samplerPassClean = false; + break; + } } + } + if (!samplerPassClean) { + constexpr SizeT kMaxMemoEntries = + PrgramImpl::BackendProgramObjectImpl::SamplerPassMemo::kMaxEntries; + samplerPassMemo.valid = false; + samplerPassMemo.count = 0; + Bool memoisable = true; + for (auto& samplerBinding : backendProgram.GetSamplerUniformBindings()) { + const auto unit = + currentProgram->GetUniformSamplerOrImageUnitIndex(samplerBinding.frontendLocation); + if (unit == -1) continue; + // Record the touched unit for the memo; a pass touching more + // units than the memo can carry (or an out-of-range unit) + // simply never memoises. + if (memoisable && samplerPassMemo.count < kMaxMemoEntries && unit >= 0 && + unit < static_cast(SamplerImpl::g_boundSamplersCache.size())) { + samplerPassMemo.units[samplerPassMemo.count++] = static_cast(unit); + } else { + memoisable = false; + } + if (samplerBinding.lastAssignedUnit != unit) { + g_GLESFuncs.glUniform1i(samplerBinding.backendLocation, unit); + samplerBinding.lastAssignedUnit = unit; + } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); - auto& samplerObject = textureUnit.GetSamplerObject(); - const auto& texture2D = textureUnit.GetBindingSlot(TextureTarget::Texture2D).GetBoundObject(); + auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& samplerObject = textureUnit.GetSamplerObject(); + const auto& texture2D = + textureUnit.GetBindingSlot(TextureTarget::Texture2D).GetBoundObject(); - // ES has no per-texture/sampler LOD bias, so the transpiled ESSL folds - // it in from a uniform (PrgramImpl::EmulateTextureLodBias). A bound - // sampler object overrides the texture's own sampler state, as in GL. - if (samplerBinding.lodBiasLocation >= 0) { - Float lodBias = 0.0f; - if (samplerObject) { - lodBias = samplerObject->GetLodBias(); - } else if (const auto sampledTarget = - SamplerUniformTextureTarget(samplerBinding.uniformType); - sampledTarget != TextureTarget::Unknown) { - const auto& boundTexture = - textureUnit.GetBindingSlot(sampledTarget).GetBoundObject(); - if (boundTexture && boundTexture->GetSamplerObject()) { - lodBias = boundTexture->GetSamplerObject()->GetLodBias(); + // ES has no per-texture/sampler LOD bias, so the transpiled ESSL folds + // it in from a uniform (PrgramImpl::EmulateTextureLodBias). A bound + // sampler object overrides the texture's own sampler state, as in GL. + if (samplerBinding.lodBiasLocation >= 0) { + Float lodBias = 0.0f; + if (samplerObject) { + lodBias = samplerObject->GetLodBias(); + } else if (const auto sampledTarget = + SamplerUniformTextureTarget(samplerBinding.uniformType); + sampledTarget != TextureTarget::Unknown) { + const auto& boundTexture = + textureUnit.GetBindingSlot(sampledTarget).GetBoundObject(); + if (boundTexture && boundTexture->GetSamplerObject()) { + lodBias = boundTexture->GetSamplerObject()->GetLodBias(); + } + } + if (lodBias != samplerBinding.lastAssignedLodBias) { + g_GLESFuncs.glUniform1f(samplerBinding.lodBiasLocation, lodBias); + samplerBinding.lastAssignedLodBias = lodBias; } } - if (lodBias != samplerBinding.lastAssignedLodBias) { - g_GLESFuncs.glUniform1f(samplerBinding.lodBiasLocation, lodBias); - samplerBinding.lastAssignedLodBias = lodBias; + const SharedPtr* rawDepthSamplerObject = + &samplerObject; + if (!*rawDepthSamplerObject && texture2D) { + rawDepthSamplerObject = &texture2D->GetSamplerObject(); } - } - const SharedPtr* rawDepthSamplerObject = &samplerObject; - if (!*rawDepthSamplerObject && texture2D) { - rawDepthSamplerObject = &texture2D->GetSamplerObject(); - } - if (samplerBinding.uniformType == GL_SAMPLER_2D && texture2D && - NeedsRawDepthFetchSampler(*rawDepthSamplerObject, texture2D->GetFormat())) { - GetRawDepthFetchSampler()->Bind(unit); - MGLOG_D("Using raw depth fetch sampler on unit %d.", unit); - } else if (samplerObject) { - auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject); - if (!backendSampler) { - auto& backendObj = SamplerImpl::g_backendSamplerObjects.GetOrCreate(samplerObject); - if (!backendObj) { - backendObj = MakeShared(); + if (samplerBinding.uniformType == GL_SAMPLER_2D && texture2D && + NeedsRawDepthFetchSampler(*rawDepthSamplerObject, texture2D->GetFormat())) { + GetRawDepthFetchSampler()->Bind(unit); + MGLOG_D("Using raw depth fetch sampler on unit %d.", unit); + } else if (samplerObject) { + auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject); + if (!backendSampler) { + auto& backendObj = + SamplerImpl::g_backendSamplerObjects.GetOrCreate(samplerObject); + if (!backendObj) { + backendObj = MakeShared(); + } + backendSampler = backendObj.get(); } - backendSampler = backendObj.get(); + backendSampler->SyncToBackend(samplerObject); + // Syncing the object's parameters is not the same as putting it on the + // unit: without this the driver kept sampling with the texture's own + // parameters and every sampler object was inert. + backendSampler->Bind(unit); + } else { + SamplerImpl::UnbindSampler(unit); } - backendSampler->SyncToBackend(samplerObject); - // Syncing the object's parameters is not the same as putting it on the - // unit: without this the driver kept sampling with the texture's own - // parameters and every sampler object was inert. - backendSampler->Bind(unit); - } else { - SamplerImpl::UnbindSampler(unit); + } + if (memoisable) { + // Snapshot the rows AFTER the pass: they are exactly what a + // clean replay must find untouched. + for (Uint i = 0; i < samplerPassMemo.count; ++i) { + samplerPassMemo.rows[i] = SamplerImpl::g_boundSamplersCache[samplerPassMemo.units[i]]; + } + samplerPassMemo.contextId = keys.contextId; + samplerPassMemo.unitBindingsEpoch = keys.unitBindingsEpoch; + samplerPassMemo.samplingGeneration = keys.samplingGeneration; + samplerPassMemo.backendStateVersion = programBackendStateVersion; + samplerPassMemo.textureContextGeneration = TextureImpl::g_textureContextGeneration; + samplerPassMemo.valid = true; } } } @@ -2753,7 +2916,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Compute programs need the same per-program resource sync as draws: // uniform-block bindings and sampler units only exist through the API // because layout(binding) is stripped from the transpiled ESSL. - BindCurrentProgramWithResources(currentProgram); + BindCurrentProgramWithResources(currentProgram, textureKeys); } GLuint GetBackendProgramId(GLuint program) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index b0791c1b..ca79a54b 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -1170,13 +1170,50 @@ namespace MobileGL::MG_Backend::DirectGLES { return !g_uboRing.creationFailed; } - Bool UboRingAllocate(SizeT size, SizeT& outOffset) { - if (size == 0 || !UboRingAvailable()) return false; - // Division-based rounding: the spec doesn't promise a power-of-two + namespace { + // Division-based rounding fallback: the spec doesn't promise a power-of-two // alignment. Slot offsets stay multiples of the alignment because every // slot size is, and wrap padding restarts at ring offset 0. - const SizeT alignedSize = - (size + g_uboRing.alignment - 1) / g_uboRing.alignment * g_uboRing.alignment; + inline SizeT UboRingAlignUp(SizeT size, SizeT alignment) { + if ((alignment & (alignment - 1)) == 0) { + return (size + alignment - 1) & ~(alignment - 1); + } + return (size + alignment - 1) / alignment * alignment; + } + Bool UboRingAllocateSlow(SizeT size, SizeT& outOffset); + } // namespace + + Bool UboRingAllocate(SizeT size, SizeT& outOffset) { + if (size == 0) return false; + // Fast path: a live ring under the current context with room before both + // the wrap boundary and the in-flight tail. Touches no GL and probes no + // frame marks - the sole caller sits behind UboRingAvailable() in the + // draw preparation, so the context checks have already run this draw. + // `tail` may be stale here (marks are only retired on Present and on the + // slow path); staleness is conservative - the in-flight span reads too + // large, the check fails, and the slow path retires marks and re-tries. + auto& ring = g_uboRing; + if (ring.id != 0 && ring.contextGeneration == g_bufferContextGeneration) { + const SizeT alignedSize = UboRingAlignUp(size, ring.alignment); + // Ring sizes are kUboRingInitialBytes (a power of two) doubled some + // number of times, so the offset modulo reduces to a mask. + static_assert((kUboRingInitialBytes & (kUboRingInitialBytes - 1)) == 0, + "ring offset mask below requires power-of-two ring sizes"); + const SizeT offset = static_cast(ring.head & (ring.size - 1)); + if (offset + alignedSize <= ring.size && + ring.head + alignedSize - ring.tail <= ring.size) { + ring.head += alignedSize; + outOffset = offset; + return true; + } + } + return UboRingAllocateSlow(size, outOffset); + } + + namespace { + Bool UboRingAllocateSlow(SizeT size, SizeT& outOffset) { + if (!UboRingAvailable()) return false; + const SizeT alignedSize = UboRingAlignUp(size, g_uboRing.alignment); if (g_uboRing.id == 0 && !CreateUboRingStorage(alignedSize)) { return false; } @@ -1253,6 +1290,7 @@ namespace MobileGL::MG_Backend::DirectGLES { outOffset = offset; return true; } + } // namespace void* UboRingMappedPtr() { return g_uboRing.mappedPtr; } Uint UboRingBufferId() { return g_uboRing.id; } @@ -3721,6 +3759,10 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("Syncing program to backend. State program ID: %u, Backend ID: %u", stateProgramObject->GetExternalIndex(), m_backendProgramId); + // Every link-derived cache below (incl. m_samplerUniformBindings and its + // lastAssignedUnit/lastAssignedLodBias program-state mirrors) is rebuilt; + // the sampler-pass memo keyed on them must not survive. + m_samplerPassMemo.valid = false; m_backendProgramUsable = true; m_snormFallbackClampOutputMask = g_snormFallbackClampOutputMask; m_unormFallbackClampOutputMask = g_unormFallbackClampOutputMask; diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index bed1da8c..2900c726 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -753,6 +753,10 @@ namespace MobileGL::MG_Backend::DirectGLES { void InvalidatePackStateCache(); } // namespace PixelStoreImpl + namespace SamplerImpl { + class BackendSamplerObject; // for PrgramImpl's sampler-pass memo rows below + } + // Image uniforms take their unit from the layout(binding=N) qualifier baked into // the transpiled ESSL; unlike samplers they must not (and in ES cannot) be // assigned through glUniform1i. @@ -799,6 +803,43 @@ namespace MobileGL::MG_Backend::DirectGLES { Float lastAssignedLodBias = 0.0f; }; + // Memo of the whole per-draw sampler-uniform pass (glUniform1i unit + // assignments, lod-bias uniform, raw-depth-fetch substitution and the + // per-unit sampler-object binds) in BindCurrentProgramWithResources. + // The pass is a pure function of the keys below, and its only driver-side + // effect is the sampler binding of each sampled unit, so replaying it as + // "do nothing" additionally requires those bindings to still be on the + // driver - the per-entry row compare against g_boundSamplersCache (the + // shadow every sampler bind in this backend already routes through). + // + // Invalidation enumeration: + // * sampler-uniform unit assignment (glUniform1i) and uniform-block + // binding edits -> frontend backendStateVersion; + // * any texture/sampler bind moving on any unit (incl. the high-water + // mark moving) -> unitBindingsEpoch; + // * any sampler parameter (incl. lod bias, compare mode) or texture + // shape/format change -> samplingGeneration; + // * another frontend context -> contextId (never-reused id); + // * ES context recreation -> textureContextGeneration; + // * relink / backend program rebuild -> SyncToBackend resets `valid` + // (it rebuilds m_samplerUniformBindings, whose lastAssignedUnit / + // lastAssignedLodBias dedup state this memo leans on); + // * any other writer moving a sampled unit's sampler binding + // (BindCurrentUnitSamplers on a unit-sampler change, scratch binds) + // -> the row snapshot compare. + struct SamplerPassMemo { + static constexpr SizeT kMaxEntries = 16; + Bool valid = false; + Uint8 count = 0; + Uint64 contextId = 0; + Uint64 unitBindingsEpoch = 0; + Uint64 samplingGeneration = 0; + Uint32 backendStateVersion = 0; + Uint textureContextGeneration = 0; + Array units{}; + Array rows{}; + }; + BackendProgramObjectImpl(); ~BackendProgramObjectImpl(); void SyncToBackend(const SharedPtr& stateProgramObject); @@ -827,6 +868,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // reflected size when the transpiled block pads differently). Int GetGlobalUboBackendBlockSize() const { return m_globalUboBackendBlockSize; } BufferImpl::UboRingAllocation& GetGlobalUboRingAllocation() { return m_globalUboRingAllocation; } + SamplerPassMemo& GetSamplerPassMemo() { return m_samplerPassMemo; } // Frontend link version this backend program (and its resource caches) was // built from; a mismatch means every link-derived cache here is stale. Uint32 GetSyncedLinkVersion() const { return m_syncedLinkVersion; } @@ -855,6 +897,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 m_lastUploadedGlobalUboVersion = ~0u; BufferImpl::UboRingAllocation m_globalUboRingAllocation; Uint32 m_syncedLinkVersion = ~0u; + SamplerPassMemo m_samplerPassMemo; }; extern Uint32 g_snormFallbackClampOutputMask;