[Perf] (MG_Backend/DirectGLES): replace per-draw global-UBO glBufferSubData with a persistent-mapped ring allocator (fence-watermark reclaimed, MOBILEGL_DISABLE_UBO_RING opt-out); scrub stale indexed-binding shadow on glDeleteBuffers

This commit is contained in:
2026-07-16 00:10:12 -04:00
parent e8d9a913d8
commit 315e9cb194
5 changed files with 350 additions and 7 deletions
+4
View File
@@ -57,6 +57,10 @@ namespace MobileGL::MG_Config {
Bool CoherentAsFlush = false;
// MOBILEGL_TRACE_SKIP_AUTODESTROY: skip teardown in the ELF destructor (Init.cpp).
Bool TraceSkipAutodestroy = false;
// MOBILEGL_DISABLE_UBO_RING: force the DirectGLES global-UBO upload back to the
// per-draw glBufferSubData path instead of the persistent-mapped ring allocator
// (negative control / driver-bug escape hatch).
Bool DisableUboRing = false;
};
extern FeaturesTable Features;
} // namespace MobileGL::MG_Config
+1
View File
@@ -121,6 +121,7 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvFlag("MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
}
inline void InitBackendType() {
+48 -7
View File
@@ -1076,14 +1076,52 @@ namespace MobileGL::MG_Backend::DirectGLES {
ZoneScopedNC("UpdateGlobalUBO", TRACY_ZONECOLOR_BACKEND);
#endif
const Uint32 uboContentVersion = currentProgram->GetUBOContentVersion();
if (backendProgram.GetLastUploadedGlobalUboVersion() != uboContentVersion) {
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, backendProgram.GetBackendGlobalUBOId());
g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, currentProgram->GetUBOSize(),
currentProgram->MapUBO());
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0);
backendProgram.SetLastUploadedGlobalUboVersion(uboContentVersion);
const SizeT uboSize = static_cast<SizeT>(currentProgram->GetUBOSize());
// Preferred path: write changed contents into a fresh slot of the
// shared persistent-mapped ring and bind it as a range. The GPU
// never reads bytes the CPU is writing, so the driver has no
// write-after-read hazard to resolve — the in-place glBufferSubData
// below forced Adreno into a ghost/stall on every uniform-dirtying
// draw (MC dirties uniforms every draw), which dominated frame time.
Bool ringBound = false;
if (BufferImpl::UboRingAvailable()) {
const SizeT bindSize =
std::max(uboSize, static_cast<SizeT>(backendProgram.GetGlobalUboBackendBlockSize()));
const Uint64 frameSerial = CurrentFrameSerial();
auto& ringSlot = backendProgram.GetGlobalUboRingAllocation();
Bool slotValid = ringSlot.ringGeneration == BufferImpl::UboRingGeneration() &&
ringSlot.frameSerial == frameSerial &&
ringSlot.contentVersion == uboContentVersion;
if (!slotValid) {
SizeT offset = 0;
if (BufferImpl::UboRingAllocate(bindSize, offset)) {
std::memcpy(static_cast<Uint8*>(BufferImpl::UboRingMappedPtr()) + offset,
currentProgram->MapUBO(), uboSize);
ringSlot = {uboContentVersion, BufferImpl::UboRingGeneration(), frameSerial,
offset};
slotValid = true;
}
}
if (slotValid) {
BufferImpl::BindBufferRangeCached(GL_UNIFORM_BUFFER, 0, BufferImpl::UboRingBufferId(),
static_cast<GLintptr>(ringSlot.offset),
static_cast<GLsizeiptr>(bindSize));
ringBound = true;
}
}
if (!ringBound) {
// Fallback (no EXT_buffer_storage / fences, or ring creation
// failed): the original in-place upload.
if (backendProgram.GetLastUploadedGlobalUboVersion() != uboContentVersion) {
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, backendProgram.GetBackendGlobalUBOId());
g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, currentProgram->GetUBOSize(),
currentProgram->MapUBO());
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0);
backendProgram.SetLastUploadedGlobalUboVersion(uboContentVersion);
}
BufferImpl::BindBufferBaseCached(GL_UNIFORM_BUFFER, 0,
backendProgram.GetBackendGlobalUBOId());
}
BufferImpl::BindBufferBaseCached(GL_UNIFORM_BUFFER, 0, backendProgram.GetBackendGlobalUBOId());
}
{
@@ -4129,6 +4167,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_completedFrameSerial.store(completed, std::memory_order_relaxed);
}
// After the watermark advanced: retire grown-away UBO-ring stores and record
// the frame's ring high-water mark for slot reclamation.
BufferImpl::UboRingOnPresent();
BufferImpl::TrimBufferPool();
}
+253
View File
@@ -9,6 +9,7 @@
#include "Managers.h"
#include "Utils.h"
#include "DirectGLES.h"
#include <Config.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -270,6 +271,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
// an older generation hold ids from a dead context.
Uint g_bufferContextGeneration = 1;
// Defined next to the indexed-binding shadow below; forward-declared so
// every glDeleteBuffers site in this namespace can scrub stale shadow
// entries (GL resets a deleted buffer's indexed bindings to 0, and a
// recycled name matching a stale shadow entry would otherwise
// false-skip the rebind).
void ScrubIndexedBufferBindingShadowForId(Uint id);
// Resources whose owning BufferObject died; ids deleted at the next
// sync point with a current ES context.
Vector<SharedPtr<BackendBufferResource>> g_deferredBufferReleases;
@@ -313,6 +321,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const std::lock_guard<std::mutex> lock(g_poolMutex);
auto& bucket = g_bufferPool[r.storageSize];
if (bucket.size() >= kMaxEntriesPerBucket || g_pooledBytes + r.storageSize > kMaxPoolBytes) {
ScrubIndexedBufferBindingShadowForId(r.id);
g_GLESFuncs.glDeleteBuffers(1, &r.id); // over budget: don't pool
r.id = 0;
return;
@@ -353,6 +362,56 @@ namespace MobileGL::MG_Backend::DirectGLES {
return 0;
}
// --- Global-UBO ring (see Managers.h) ------------------------------------
constexpr SizeT kUboRingInitialBytes = 4u * 1024u * 1024u;
constexpr SizeT kUboRingMaxBytes = 64u * 1024u * 1024u;
struct UboRingState {
Uint id = 0;
Uint8* mappedPtr = nullptr;
SizeT size = 0;
// Monotonic linear cursors: `head` counts every byte ever allocated
// (incl. wrap padding); everything below `tail` is GPU-complete. Ring
// offset of a linear position is pos % size, so in-flight bytes are
// head - tail and must stay <= size.
Uint64 head = 0;
Uint64 tail = 0;
Uint32 generation = 0; // bumped on every (re)create/grow; 0 = never valid
Uint contextGeneration = 0;
SizeT alignment = 256;
// A hard storage-creation failure under this context; stop retrying
// per draw (cleared when the context generation moves on).
Bool creationFailed = false;
};
UboRingState g_uboRing;
// Grown-away ring stores: deletable only once the GPU finished the last
// frame that could reference them (same watermark as the buffer pool).
struct RetiredUboRing {
Uint id = 0;
Uint contextGeneration = 0;
Uint64 retireSerial = 0;
};
Vector<RetiredUboRing> g_retiredUboRings;
// Present()-time high-water marks: every byte below headAtPresent was
// written during frames <= frameSerial, so once frameSerial completes,
// tail may advance to headAtPresent. FIFO by construction.
struct UboRingFrameMark {
Uint64 frameSerial = 0;
Uint64 headAtPresent = 0;
};
Vector<UboRingFrameMark> g_uboRingFrameMarks;
// The ES context the ring's id/map belonged to is gone (or was never
// seen): drop every handle without GL calls and re-arm creation.
void ResetUboRingForNewContext() {
g_uboRing = {};
g_uboRing.contextGeneration = g_bufferContextGeneration;
g_retiredUboRings.clear();
g_uboRingFrameMarks.clear();
}
GLESBufferResource* ResourceOf(BufferObject& bufferObject) {
return static_cast<GLESBufferResource*>(bufferObject.GetBackendResource().get());
}
@@ -438,6 +497,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Need a fresh id: glBufferStorage fails on a buffer that already has
// immutable storage, and any prior mutable store is replaced anyway.
if (resource->id != 0) {
ScrubIndexedBufferBindingShadowForId(resource->id);
g_GLESFuncs.glDeleteBuffers(1, &resource->id);
resource->id = 0;
}
@@ -565,6 +625,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_boundArrayBufferKnown && g_boundArrayBufferId == glesResource->id) {
InvalidateArrayBufferBindingCache();
}
ScrubIndexedBufferBindingShadowForId(glesResource->id);
g_GLESFuncs.glDeleteBuffers(1, &glesResource->id);
glesResource->id = 0;
}
@@ -602,6 +663,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
void OnBackendContextDestroyed() {
UnregisterBufferBackendOps();
++g_bufferContextGeneration;
// The global-UBO ring's id and persistent map died with the context;
// drop the handles (no GL) and let the next draw recreate the ring.
ResetUboRingForNewContext();
}
void ProcessDeferredBufferReleases() {
@@ -625,6 +689,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (g_boundArrayBufferKnown && g_boundArrayBufferId == glesResource->id) {
InvalidateArrayBufferBindingCache();
}
ScrubIndexedBufferBindingShadowForId(glesResource->id);
g_GLESFuncs.glDeleteBuffers(1, &glesResource->id);
glesResource->id = 0;
}
@@ -769,6 +834,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (glTarget == GL_SHADER_STORAGE_BUFFER) return &g_indexedSSBOBindings[index];
return nullptr;
}
// glDeleteBuffers resets the deleted buffer's bindings (indexed ones
// included) to 0 in the current context; mirror that in the shadow, or a
// later buffer recycling the same name with a matching range would
// false-skip its rebind. Default IndexedBufferBinding{} == base(0) ==
// the post-delete GL state.
void ScrubIndexedBufferBindingShadowForId(Uint id) {
if (id == 0) return;
for (auto& binding : g_indexedUBOBindings) {
if (binding.id == id) binding = {};
}
for (auto& binding : g_indexedSSBOBindings) {
if (binding.id == id) binding = {};
}
}
} // namespace
void BindBufferBaseCached(GLenum glTarget, Uint index, Uint id) {
@@ -812,6 +892,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto& bucket = g_bufferPool[oldestKey];
PooledBuffer& e = bucket[oldestIdx];
if (e.contextGeneration == g_bufferContextGeneration && e.id != 0) {
ScrubIndexedBufferBindingShadowForId(e.id);
g_GLESFuncs.glDeleteBuffers(1, &e.id);
}
g_pooledBytes -= e.size;
@@ -827,6 +908,167 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_bufferPool.clear();
g_pooledBytes = 0;
}
// --- Global-UBO ring (see Managers.h) ------------------------------------
namespace {
// (Re)create the ring store with room for at least minBytes. Any live
// store is retired (deleted once the GPU finished the last frame that
// could reference its slots), never deleted in place. Returns false and
// leaves the current store untouched when minBytes cannot fit under the
// size cap; a GL failure loses the store and latches creationFailed so
// draws stop retrying under this context.
Bool CreateUboRingStorage(SizeT minBytes) {
SizeT newSize = kUboRingInitialBytes;
while (newSize < minBytes) newSize *= 2;
if (newSize > kUboRingMaxBytes) return false;
if (g_uboRing.id != 0) {
g_retiredUboRings.push_back(
{g_uboRing.id, g_uboRing.contextGeneration, DirectGLES::CurrentFrameSerial() + 1});
}
const Uint32 nextGeneration = g_uboRing.generation + 1;
g_uboRing.id = 0;
g_uboRing.mappedPtr = nullptr;
Uint id = 0;
g_GLESFuncs.glGenBuffers(1, &id);
if (id != 0) {
BindBufferId(TempBufferTarget, id);
g_GLESFuncs.glBufferStorageEXT(TempBufferTarget, static_cast<GLsizeiptr>(newSize), nullptr,
GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit);
void* ptr = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast<GLsizeiptr>(newSize),
GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit);
if (!ptr) {
g_GLESFuncs.glDeleteBuffers(1, &id);
id = 0;
} else {
g_uboRing.mappedPtr = static_cast<Uint8*>(ptr);
}
}
if (id == 0) {
MGLOG_E("Global-UBO ring: persistent storage creation failed (%zu bytes); "
"falling back to glBufferSubData uploads.",
newSize);
g_uboRing.creationFailed = true;
return false;
}
const GLint capsAlignment = g_GLESCapabilities.UniformBufferOffsetAlignment;
SizeT alignment = capsAlignment > 0 ? static_cast<SizeT>(capsAlignment) : 256;
// The cursor math masks with (alignment - 1); spec doesn't promise a
// power of two, so round up to one.
SizeT pow2 = 1;
while (pow2 < alignment) pow2 <<= 1;
g_uboRing.id = id;
g_uboRing.size = newSize;
g_uboRing.head = 0;
g_uboRing.tail = 0;
g_uboRing.generation = nextGeneration;
g_uboRing.alignment = pow2;
g_uboRingFrameMarks.clear();
return true;
}
} // namespace
Bool UboRingAvailable() {
if (MG_Config::Features.DisableUboRing) return false;
// Reclamation rides the Present fence watermark; without working fences
// slots would never be provably GPU-idle (same rule as IsPoolable).
if (!g_GLESFuncs.glBufferStorageEXT || !g_GLESFuncs.glMapBufferRange || !g_GLESFuncs.glGenBuffers ||
!g_GLESFuncs.glFenceSync || !g_GLESFuncs.glGetSynciv) {
return false;
}
if (!CanTouchGLNow()) return false;
if (g_uboRing.contextGeneration != g_bufferContextGeneration) {
ResetUboRingForNewContext();
}
return !g_uboRing.creationFailed;
}
Bool UboRingAllocate(SizeT size, SizeT& outOffset) {
if (size == 0 || !UboRingAvailable()) return false;
const SizeT alignedSize = (size + g_uboRing.alignment - 1) & ~(g_uboRing.alignment - 1);
if (g_uboRing.id == 0 && !CreateUboRingStorage(alignedSize)) {
return false;
}
// Advance tail past every frame the GPU provably finished.
const Uint64 completed = DirectGLES::CompletedFrameSerial();
SizeT retiredMarks = 0;
for (const auto& mark : g_uboRingFrameMarks) {
if (mark.frameSerial > completed) break;
if (mark.headAtPresent > g_uboRing.tail) g_uboRing.tail = mark.headAtPresent;
++retiredMarks;
}
if (retiredMarks > 0) {
g_uboRingFrameMarks.erase(g_uboRingFrameMarks.begin(),
g_uboRingFrameMarks.begin() + static_cast<std::ptrdiff_t>(retiredMarks));
}
// A slot may not straddle the ring end; pad the cursor to the boundary.
SizeT offset = static_cast<SizeT>(g_uboRing.head % g_uboRing.size);
if (offset + alignedSize > g_uboRing.size) {
g_uboRing.head += g_uboRing.size - offset;
offset = 0;
}
if (g_uboRing.head + alignedSize - g_uboRing.tail > g_uboRing.size) {
// In-flight span would overrun live slots: grow instead of overwrite.
if (CreateUboRingStorage(std::max(g_uboRing.size * 2, alignedSize))) {
offset = 0;
} else if (g_uboRing.creationFailed) {
return false; // store lost; callers fall back to glBufferSubData
} else {
// At the size cap (>kUboRingMaxBytes of uniforms in flight — not a
// real workload): drain the GPU once rather than corrupt live slots.
if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish();
g_uboRing.tail = g_uboRing.head;
g_uboRingFrameMarks.clear();
offset = static_cast<SizeT>(g_uboRing.head % g_uboRing.size);
if (offset + alignedSize > g_uboRing.size) {
g_uboRing.head += g_uboRing.size - offset;
offset = 0;
}
}
}
g_uboRing.head += alignedSize;
outOffset = offset;
return true;
}
void* UboRingMappedPtr() { return g_uboRing.mappedPtr; }
Uint UboRingBufferId() { return g_uboRing.id; }
Uint32 UboRingGeneration() { return g_uboRing.generation; }
void UboRingOnPresent() {
if (!CanTouchGLNow()) return;
// Delete grown-away stores the GPU is provably done with.
const Uint64 completed = DirectGLES::CompletedFrameSerial();
for (SizeT i = g_retiredUboRings.size(); i-- > 0;) {
RetiredUboRing& entry = g_retiredUboRings[i];
const Bool staleContext = entry.contextGeneration != g_bufferContextGeneration;
if (!staleContext && entry.retireSerial > completed) continue;
if (!staleContext && entry.id != 0) {
ScrubIndexedBufferBindingShadowForId(entry.id);
g_GLESFuncs.glDeleteBuffers(1, &entry.id);
}
g_retiredUboRings[i] = g_retiredUboRings.back();
g_retiredUboRings.pop_back();
}
if (g_uboRing.id == 0 || g_uboRing.contextGeneration != g_bufferContextGeneration) return;
// Record this frame's high-water mark (Present just fenced the serial now
// reported by CurrentFrameSerial()). A fence-less Present repeats the
// serial; fold into the existing mark.
const Uint64 serial = DirectGLES::CurrentFrameSerial();
if (!g_uboRingFrameMarks.empty() && g_uboRingFrameMarks.back().frameSerial == serial) {
g_uboRingFrameMarks.back().headAtPresent = g_uboRing.head;
} else {
g_uboRingFrameMarks.push_back({serial, g_uboRing.head});
}
}
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -2576,13 +2818,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
void BackendProgramObjectImpl::CacheResourceLocations(
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
m_globalUboBackendBlockIndex = -1;
m_globalUboBackendBlockSize = 0;
m_lastUploadedGlobalUboVersion = ~0u;
m_globalUboRingAllocation = {};
if (stateProgramObject->GetUBOSize() > 0) {
const Uint blockIndex =
g_GLESFuncs.glGetUniformBlockIndex(m_backendProgramId, MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME);
if (blockIndex != GL_INVALID_INDEX) {
m_globalUboBackendBlockIndex = static_cast<Int>(blockIndex);
g_GLESFuncs.glUniformBlockBinding(m_backendProgramId, blockIndex, 0);
// Ring bindings are ranges and must span the block as the backend
// compiled it (its std140 padding may exceed the frontend's
// SPIR-V-reflected size).
if (g_GLESFuncs.glGetActiveUniformBlockiv) {
GLint blockDataSize = 0;
g_GLESFuncs.glGetActiveUniformBlockiv(m_backendProgramId, blockIndex,
GL_UNIFORM_BLOCK_DATA_SIZE, &blockDataSize);
m_globalUboBackendBlockSize = static_cast<Int>(blockDataSize);
}
} else {
MGLOG_W("Program %u has frontend global UBO storage, but backend has no %s block.",
stateProgramObject->GetExternalIndex(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME);
+44
View File
@@ -189,6 +189,43 @@ namespace MobileGL::MG_Backend::DirectGLES {
// without glDeleteBuffers (called when the ES context is going away).
void TrimBufferPool();
void ClearBufferPool();
// --- Global-UBO ring ------------------------------------------------------
// One persistently+coherently mapped buffer (EXT_buffer_storage) shared by
// every program's lowered default-uniform block. Each content change is
// bump-allocated into a fresh slot and bound with glBindBufferRange, so the
// CPU never rewrites bytes the GPU may still be reading — the per-draw
// glBufferSubData into one static UBO forced Adreno to resolve that
// write-after-read hazard on every uniform-dirtying draw (MC dirties
// uniforms every draw). Reclamation rides the Present() frame-fence
// watermark; no ring bytes are recycled before their frame's GPU work
// completed.
//
// A program's cached slot, reusable within one frame while the frontend UBO
// content version is unchanged. Cross-frame reuse is intentionally not
// attempted: later same-frame allocations may recycle bytes of completed
// frames, so re-referencing them would need per-bind pinning — rewriting
// GetUBOSize() bytes once per program per frame is far cheaper.
struct UboRingAllocation {
Uint32 contentVersion = ~0u; // frontend UBO content version held at `offset`
Uint32 ringGeneration = 0; // ring identity the slot lives in (0 = never valid)
Uint64 frameSerial = ~Uint64{0}; // frame the slot was written in
SizeT offset = 0;
};
// False when the feature is disabled, EXT_buffer_storage / fences are
// missing, the ES context is not current, or ring creation already failed
// under this context (callers then take the legacy glBufferSubData path).
Bool UboRingAvailable();
// Bump-allocate `size` bytes aligned to GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT.
// Grows the ring (new GL store, generation bump) when the in-flight span
// would be overrun. Returns false when storage (re)creation fails.
Bool UboRingAllocate(SizeT size, SizeT& outOffset);
void* UboRingMappedPtr();
Uint UboRingBufferId();
Uint32 UboRingGeneration();
// Present()-time upkeep: records the frame's high-water mark for reclamation
// and deletes grown-away ring stores once the GPU is done with them.
void UboRingOnPresent();
} // namespace BufferImpl
namespace VertexArrayImpl {
@@ -392,6 +429,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<SamplerUniformBinding>& GetSamplerUniformBindings() { return m_samplerUniformBindings; }
Uint32 GetLastUploadedGlobalUboVersion() const { return m_lastUploadedGlobalUboVersion; }
void SetLastUploadedGlobalUboVersion(Uint32 version) { m_lastUploadedGlobalUboVersion = version; }
// Backend-reported GL_UNIFORM_BLOCK_DATA_SIZE of the global block; ring
// bindings must span at least this much (may exceed the frontend's
// reflected size when the transpiled block pads differently).
Int GetGlobalUboBackendBlockSize() const { return m_globalUboBackendBlockSize; }
BufferImpl::UboRingAllocation& GetGlobalUboRingAllocation() { return m_globalUboRingAllocation; }
// 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; }
@@ -410,9 +452,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_isInitialized = false;
Int m_globalUboBackendBlockIndex = -1;
Int m_globalUboBackendBlockSize = 0;
Vector<Int> m_uniformBlockBackendIndices; // frontend block index -> backend index (-1 = absent)
Vector<SamplerUniformBinding> m_samplerUniformBindings;
Uint32 m_lastUploadedGlobalUboVersion = ~0u;
BufferImpl::UboRingAllocation m_globalUboRingAllocation;
Uint32 m_syncedLinkVersion = ~0u;
};