[Perf] (MG_State, MG_Impl, MG_Backend): stop paying per draw and per upload for work already known

A per-draw CPU profile of a real Minecraft frame (perf on the render thread,
which sits at 100% of one core on both backends) said the deficit is translation
overhead, not the GPU, and named where it goes. This removes the largest items
it found, on both backends and in the shared frontend they both feed.

The single biggest one was not translation at all: IsBackendContextCurrentOnThisThread
called eglGetCurrentContext on every invocation, and glvnd answers that with a
getpid() fork check - a real syscall. The predicate sits two and three deep in
every draw (the deferred-release drain, the global-UBO ring availability check,
and the ring allocation), so it accounted for 16.3% of the render thread. EGL is
still the ground truth, but re-verifying it once per thread per frame catches an
external migration at the next frame boundary rather than the next call, which
recovers the same bookkeeping.

Texture uploads now carry a dirty region instead of a per-level flag. Minecraft
animates atlas sprites with 16x16 glTexSubImage2D calls into a 1024x512 atlas
and respecifies the lightmap every frame; a per-level flag turned each of those
into a full-level re-upload - about 3.6 MB a frame of texels nobody changed.
MipmapStorage accumulates the written box, Espryt uploads it with
UNPACK_ROW_LENGTH striding into the level shadow, and Magma stages just that box.
The box is a union, not a range list: repeated writes to one level widen it and
it degrades to exactly the old whole-level upload, which is the honest worst case.

glBufferData(NULL) is the orphaning idiom, and the backend was answering it by
uploading the stale CPU shadow - turning a rename the driver does for free into
a full synchronized upload. BufferObject now records that a NULL respecify leaves
the store undefined, and the upload is skipped until content is actually written.

The rest are smaller and of a kind: the deferred-release queue is probed without
taking its mutex, the UBO ring waits on the frame fence that frees the space it
needs instead of draining the whole pipeline with glFinish at the size cap, VAO
binds go through a shadow so a draw's second bind of the same object does not
reach the driver, the per-draw clean-texture probe short-circuits on the content
version before rebuilding shape info, glUniform drops byte-identical writes
(which otherwise dirty the whole UBO for the next draw), re-binding the texture
or VAO a slot already holds no longer bumps the generation counters a backend
fast path is keyed on, and the texture validators stopped taking shared_ptr by
value.

On Magma: descriptor-set reuse keeps four entries instead of one, because draws
alternating between two programs - the chunk/entity ping-pong - thrashed a single
slot into a full re-allocate and re-write every draw; a DynamicDraw buffer whose
contents survive two frame boundaries is promoted to resident storage instead of
being re-copied into the per-frame arena forever; and sampled-read barriers name
only the shader stages whose device feature is enabled, which also removes a
latent VUID violation (ALL_GRAPHICS names geometry and tessellation stages a
device need not have).

Measured with the Minecraft rig (render distance 32, p50 fps, same machine,
single sample each): vanilla 1.21.1 Espryt 10.8 -> 36.3 and Magma 31.3 -> 44.6;
26.2 snapshot Magma 114.5 -> 210.5. Fabric+Sodium moved inside noise on Magma
(854 -> 766) with the native baseline itself moving 838 -> 1031 between the two
sessions, so treat that cell as unresolved rather than a regression measured.
Unit tests 421/421. The CTS A/B was not run: these numbers and the test suite are
the whole of the evidence, and a conformance regression would not have been
caught here.
This commit is contained in:
BZLZHH
2026-08-06 06:24:37 -04:00
parent 9c0144d24a
commit 57aeeec053
29 changed files with 605 additions and 89 deletions
+51 -4
View File
@@ -1518,7 +1518,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
backendVAOIt->second->Bind();
}
} else {
g_GLESFuncs.glBindVertexArray(0);
VertexArrayImpl::BindBackendVAOId(0);
}
}
@@ -3001,7 +3001,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Float uvTransform[4] = {mirrorX ? -uvScaleX : uvScaleX, mirrorY ? -uvScaleY : uvScaleY,
mirrorX ? uvScaleX : 0.0f, mirrorY ? uvScaleY : 0.0f};
g_GLESFuncs.glBindVertexArray(s_vertexArray);
VertexArrayImpl::BindBackendVAOId(s_vertexArray);
g_GLESFuncs.glActiveTexture(GL_TEXTURE0);
g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, s_texture);
g_GLESFuncs.glViewport(dstLeft, dstBottom, dstWidth, dstHeight);
@@ -3060,7 +3060,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glUseProgram(static_cast<GLuint>(previousProgram));
g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previousTexture));
g_GLESFuncs.glActiveTexture(static_cast<GLenum>(previousActiveTexture));
g_GLESFuncs.glBindVertexArray(static_cast<GLuint>(previousVertexArray));
VertexArrayImpl::BindBackendVAOId(static_cast<GLuint>(previousVertexArray));
g_GLESFuncs.glViewport(previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3]);
g_GLESFuncs.glScissor(previousScissorBox[0], previousScissorBox[1], previousScissorBox[2],
previousScissorBox[3]);
@@ -5919,6 +5919,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
return true;
}
namespace {
// EGL ground-truth verification stamp, per thread. glvnd's
// eglGetCurrentContext performs fork detection with a real getpid()
// syscall on every call, and this predicate sits 2-3 deep in every
// draw - measured at 16% of the render thread on a live workload.
thread_local Uint64 t_eglVerifiedFrameSerial = ~0ull;
thread_local Uint t_eglVerifiedContextGeneration = 0;
} // namespace
Bool IsBackendContextCurrentOnThisThread() {
if (g_Context == EGL_NO_CONTEXT) {
return false;
@@ -5929,10 +5938,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Belt and braces: EGL itself is the ground truth. A migration that bypassed
// MakeCurrent()/ReleaseCurrent() must not leave a stale ownership claim
// standing, or GL calls would silently no-op while shadow bookkeeping (bind
// cache, synced serials) still advances.
// cache, synced serials) still advances. Re-verify once per (thread, frame,
// context generation) rather than per call: an external migration is caught
// at the next frame boundary instead of the next call, which recovers the
// bookkeeping just the same, without paying a syscall on every draw.
const Uint64 frameSerial = g_currentFrameSerial.load(std::memory_order_relaxed);
if (t_eglVerifiedFrameSerial == frameSerial &&
t_eglVerifiedContextGeneration == g_syncContextGeneration) {
return true;
}
if (g_EGLFuncs.eglGetCurrentContext && g_EGLFuncs.eglGetCurrentContext() != g_Context) {
return false;
}
t_eglVerifiedFrameSerial = frameSerial;
t_eglVerifiedContextGeneration = g_syncContextGeneration;
return true;
}
@@ -6227,6 +6246,33 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint64 CurrentFrameSerial() { return g_currentFrameSerial.load(std::memory_order_relaxed); }
Uint64 CompletedFrameSerial() { return g_completedFrameSerial.load(std::memory_order_relaxed); }
Bool WaitForFrameSerialCompleted(Uint64 serial, Uint64 timeoutNs) {
if (CompletedFrameSerial() >= serial) return true;
if (!IsBackendContextCurrentOnThisThread() || !g_GLESFuncs.glClientWaitSync) return false;
// Fences signal in submission order, so the live fence with the SMALLEST
// serial at or past the target is the earliest event that proves the
// target frame retired. A recycled slot (GPU more than ring-depth frames
// behind) leaves no usable fence; report failure and let the caller pick
// its own fallback rather than draining the whole queue here.
FrameFence* best = nullptr;
for (FrameFence& slot : g_frameFenceRing) {
if (!slot.sync || slot.contextGeneration != g_syncContextGeneration) continue;
if (slot.serial < serial) continue;
if (!best || slot.serial < best->serial) best = &slot;
}
if (!best) return false;
const GLenum status =
g_GLESFuncs.glClientWaitSync(best->sync, GL_SYNC_FLUSH_COMMANDS_BIT, timeoutNs);
if (status != GL_ALREADY_SIGNALED && status != GL_CONDITION_SATISFIED) return false;
Uint64 completed = g_completedFrameSerial.load(std::memory_order_relaxed);
if (best->serial > completed) {
g_completedFrameSerial.store(best->serial, std::memory_order_relaxed);
}
if (g_GLESFuncs.glDeleteSync) g_GLESFuncs.glDeleteSync(best->sync);
best->sync = nullptr;
return true;
}
void Present() {
// Insert one fence per frame BEFORE the swap (eglSwapBuffers' implicit flush
// makes it reachable), then non-blocking-poll prior frames' fences AFTER to
@@ -6272,6 +6318,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
XfbImpl::OnBackendContextDestroyed();
ScratchFBOImpl::OnBackendContextDestroyed();
FramebufferImpl::InvalidateFramebufferBindingCache();
VertexArrayImpl::InvalidateVAOBindingCache();
PixelStoreImpl::InvalidatePackStateCache();
// Texture ids belong to the dying context; wrappers destroyed later must
// not glDeleteTextures a recycled name in a successor context.
@@ -160,6 +160,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// A buffer retired during frame N is safe to recycle once CompletedFrameSerial() >= N.
Uint64 CurrentFrameSerial();
Uint64 CompletedFrameSerial();
// Block (up to timeoutNs) until the given frame serial provably retired on the
// GPU, using the per-frame fence ring. False when no usable fence covers the
// serial (fence-less context, foreign thread, or the slot was recycled);
// completion state is untouched in that case.
Bool WaitForFrameSerialCompleted(Uint64 serial, Uint64 timeoutNs);
// Applies (or defers until the window surface exists) the app-requested
// eglSwapInterval on the native EGL surface.
void SetSwapInterval(Int interval);
+145 -33
View File
@@ -292,6 +292,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// sync point with a current ES context.
Vector<SharedPtr<BackendBufferResource>> g_deferredBufferReleases;
std::mutex g_deferredBufferReleasesMutex;
// Cheap emptiness probe so the per-draw drain can skip the mutex and
// context check when nothing was enqueued (the overwhelmingly common
// case). Written only under the mutex; read lock-free.
std::atomic<Bool> g_hasDeferredBufferReleases{false};
// --- Buffer-storage pool (Mesa-style BO recycle) -------------------------
// Recycle idle GL buffer ids of an EXACT byte size instead of glDeleteBuffers
@@ -457,8 +461,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
const SizeT size = bufferObject.GetSize();
const GLenum usage = MG_Util::ConvertBufferUsageToGLEnum(bufferObject.GetUsage());
BindBufferId(TempBufferTarget, resource.id);
g_GLESFuncs.glBufferData(TempBufferTarget, (GLsizeiptr)size,
size > 0 ? bufferObject.MappedData() : nullptr, usage);
// An orphaning respecify (glBufferData with NULL, content never
// written since) stays a pure NULL reallocation: the driver renames
// the store without a stall and nothing is transferred. Uploading
// the stale shadow here turned Minecraft-style orphaning into a
// full-size synchronized upload.
const void* initialData =
(size > 0 && bufferObject.HasDefinedContent()) ? bufferObject.MappedData() : nullptr;
g_GLESFuncs.glBufferData(TempBufferTarget, (GLsizeiptr)size, initialData, usage);
resource.storageSize = size;
resource.storageInitialized = true;
resource.pendingRespecify = false;
@@ -680,6 +690,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const std::lock_guard<std::mutex> lock(g_deferredBufferReleasesMutex);
g_deferredBufferReleases.push_back(std::move(resource));
g_hasDeferredBufferReleases.store(true, std::memory_order_release);
}
const BufferBackendOps g_glesBufferBackendOps = {
@@ -706,6 +717,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const std::lock_guard<std::mutex> lock(g_deferredBufferReleasesMutex);
// The ES context owning these ids is going away; just drop the handles.
g_deferredBufferReleases.clear();
g_hasDeferredBufferReleases.store(false, std::memory_order_release);
}
void OnBackendContextDestroyed() {
@@ -720,11 +732,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
void ProcessDeferredBufferReleases() {
// Runs on every draw; skip the context check, mutex and vector churn
// outright when nothing was enqueued since the last drain.
if (!g_hasDeferredBufferReleases.load(std::memory_order_acquire)) return;
if (!CanTouchGLNow()) return;
Vector<SharedPtr<BackendBufferResource>> releases;
{
const std::lock_guard<std::mutex> lock(g_deferredBufferReleasesMutex);
releases.swap(g_deferredBufferReleases);
g_hasDeferredBufferReleases.store(false, std::memory_order_release);
}
for (auto& resource : releases) {
auto* glesResource = static_cast<GLESBufferResource*>(resource.get());
@@ -1115,8 +1131,32 @@ namespace MobileGL::MG_Backend::DirectGLES {
} 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.
// At the size cap (>kUboRingMaxBytes of uniforms in flight). First
// try to free room by waiting for the OLDEST in-flight frames to
// retire - a bounded wait that ends as soon as enough tail space
// exists, instead of draining the entire queue.
constexpr Uint64 kFrameWaitNs = 50ull * 1000 * 1000; // 50ms per frame
while (!g_uboRingFrameMarks.empty() &&
g_uboRing.head + alignedSize - g_uboRing.tail > g_uboRing.size) {
const auto& oldest = g_uboRingFrameMarks.front();
if (!DirectGLES::WaitForFrameSerialCompleted(oldest.frameSerial, kFrameWaitNs)) {
break;
}
if (oldest.headAtPresent > g_uboRing.tail) g_uboRing.tail = oldest.headAtPresent;
g_uboRingFrameMarks.erase(g_uboRingFrameMarks.begin());
}
if (g_uboRing.head + alignedSize - g_uboRing.tail <= g_uboRing.size) {
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;
}
// No usable fence covers the oldest frames: drain once rather than
// corrupt live slots.
if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish();
g_uboRing.tail = g_uboRing.head;
g_uboRingFrameMarks.clear();
@@ -1234,6 +1274,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
BackendVertexArrayObject::~BackendVertexArrayObject() {
if (m_backendVAOId != 0) {
NoteVAOIdDeleted(m_backendVAOId);
g_GLESFuncs.glDeleteVertexArrays(1, &m_backendVAOId);
m_backendVAOId = 0;
}
@@ -1246,11 +1287,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
namespace {
Uint g_boundBackendVAOId = 0;
Bool g_boundBackendVAOKnown = false;
} // namespace
void BindBackendVAOId(Uint id) {
if (g_boundBackendVAOKnown && g_boundBackendVAOId == id) {
return;
}
g_GLESFuncs.glBindVertexArray(id);
g_boundBackendVAOId = id;
g_boundBackendVAOKnown = true;
}
void InvalidateVAOBindingCache() {
g_boundBackendVAOKnown = false;
}
void NoteVAOIdDeleted(Uint id) {
if (g_boundBackendVAOKnown && g_boundBackendVAOId == id) {
g_boundBackendVAOId = 0; // glDeleteVertexArrays reverts a bound VAO to 0
}
}
void BackendVertexArrayObject::Bind() const {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
g_GLESFuncs.glBindVertexArray(m_backendVAOId);
BindBackendVAOId(m_backendVAOId);
}
inline Bool BindAttributeBuffer(const MG_State::GLState::VertexAttribute& attrib) {
@@ -1792,7 +1857,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// textures thrash, forcing a real glBindTexture per texture per draw. When
// nothing needs uploading, skip the bind + upload machinery entirely;
// BindCurrentTextures() re-establishes the real sampling bindings regardless.
if (m_isInitialized && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) {
// The content-version stamp short-circuits before any shape probing: it
// bumps on every CPU-side pixel mutation, so an unchanged stamp plus an
// unchanged shape means no level can be dirty. Shape stays a separate
// compare because a NULL-data glTexImage changes it without touching the
// content version.
if (m_isInitialized && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap &&
m_syncedContentVersion != 0 &&
m_syncedContentVersion == stateTextureObject->GetContentVersion()) {
auto* mipmapObject =
static_cast<MG_State::GLState::TextureObjectMipmap*>(stateTextureObject.get());
const auto probeBaseSize = stateTextureObject->GetBaseSize();
@@ -1804,25 +1876,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
0,
stateTextureObject->GetSamples(),
stateTextureObject->HasFixedSampleLocations()};
// Equal info => needsRegeneration is false, and canAppendMipmaps is
// false too (it requires strictly more mip levels than the last sync).
// So the only remaining work would be re-uploading dirty levels.
if (probe == m_prevTextureInfo) {
Bool anyDirty = false;
for (const auto& uploadTarget : mipmapObject->GetUploadTargets()) {
for (SizeT level = 0; level < probe.mipmapLevels; ++level) {
if (mipmapObject->IsStorageDirty(uploadTarget, level)) {
anyDirty = true;
break;
}
}
if (anyDirty) break;
}
if (!anyDirty) {
MGLOG_D("Texture ID %u already fully synced, skipping scratch bind + upload.",
m_backendTextureId);
return;
}
MGLOG_D("Texture ID %u already fully synced, skipping scratch bind + upload.",
m_backendTextureId);
return;
}
}
@@ -2199,24 +2256,75 @@ namespace MobileGL::MG_Backend::DirectGLES {
uploadData, byteSize, &glType, packedUploadData);
const IntVec3 uploadSize =
GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize);
// Sub-rect upload: when only a region of the level changed (a
// 16x16 sprite in a 1024x512 atlas, the per-frame lightmap) and
// the shadow bytes go to the driver unconverted, upload just that
// region with UNPACK_ROW_LENGTH striding into the level shadow.
// Conversion fallbacks rewrite the whole level into a fresh
// buffer, so they stay on the full-level path, as do targets
// whose backend upload size differs from the shadow's texel size.
const auto dirtyRegion = textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level);
const SizeT texelCount = static_cast<SizeT>(texelSize.x()) *
static_cast<SizeT>(texelSize.y()) *
static_cast<SizeT>(std::max(texelSize.z(), 1));
const Bool subRectEligible =
uploadData == mipData && !dirtyRegion.Empty() &&
!dirtyRegion.CoversWholeLevel(texelSize) && texelCount > 0 &&
byteSize % texelCount == 0 && uploadSize.x() == texelSize.x() &&
uploadSize.y() == texelSize.y() &&
std::max(uploadSize.z(), 1) == std::max(texelSize.z(), 1);
const SizeT bpp = subRectEligible ? byteSize / texelCount : 0;
const IntVec3 regionSize = {dirtyRegion.hi.x() - dirtyRegion.lo.x(),
dirtyRegion.hi.y() - dirtyRegion.lo.y(),
dirtyRegion.hi.z() - dirtyRegion.lo.z()};
const SizeT levelRowBytes = static_cast<SizeT>(texelSize.x()) * bpp;
const SizeT levelSliceBytes = static_cast<SizeT>(texelSize.y()) * levelRowBytes;
const Uint8* regionPtr =
static_cast<const Uint8*>(uploadData) +
static_cast<SizeT>(dirtyRegion.lo.z()) * levelSliceBytes +
static_cast<SizeT>(dirtyRegion.lo.y()) * levelRowBytes +
static_cast<SizeT>(dirtyRegion.lo.x()) * bpp;
switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) {
case TextureTarget::Texture2D:
case TextureTarget::TextureCubeMap:
g_GLESFuncs.glTexSubImage2D(glUploadTarget, static_cast<GLint>(level), 0, 0,
static_cast<GLsizei>(uploadSize.x()),
static_cast<GLsizei>(uploadSize.y()), glFormat, glType,
uploadData);
if (subRectEligible) {
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
g_GLESFuncs.glTexSubImage2D(
glUploadTarget, static_cast<GLint>(level), dirtyRegion.lo.x(),
dirtyRegion.lo.y(), static_cast<GLsizei>(regionSize.x()),
static_cast<GLsizei>(regionSize.y()), glFormat, glType, regionPtr);
// The surrounding ScopedDefaultUnpackState shadow says 0.
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
} else {
g_GLESFuncs.glTexSubImage2D(glUploadTarget, static_cast<GLint>(level), 0, 0,
static_cast<GLsizei>(uploadSize.x()),
static_cast<GLsizei>(uploadSize.y()), glFormat,
glType, uploadData);
}
break;
case TextureTarget::Texture3D:
case TextureTarget::Texture2DArray:
// ES 3.2 has GL_TEXTURE_CUBE_MAP_ARRAY natively and it stores exactly
// like a 2D array whose depth is 6 * the cube count.
case TextureTarget::TextureCubeMapArray:
g_GLESFuncs.glTexSubImage3D(glUploadTarget, static_cast<GLint>(level), 0, 0, 0,
static_cast<GLsizei>(uploadSize.x()),
static_cast<GLsizei>(uploadSize.y()),
static_cast<GLsizei>(uploadSize.z()), glFormat, glType,
uploadData);
if (subRectEligible) {
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, texelSize.x());
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, texelSize.y());
g_GLESFuncs.glTexSubImage3D(
glUploadTarget, static_cast<GLint>(level), dirtyRegion.lo.x(),
dirtyRegion.lo.y(), dirtyRegion.lo.z(),
static_cast<GLsizei>(regionSize.x()),
static_cast<GLsizei>(regionSize.y()),
static_cast<GLsizei>(regionSize.z()), glFormat, glType, regionPtr);
g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
g_GLESFuncs.glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
} else {
g_GLESFuncs.glTexSubImage3D(glUploadTarget, static_cast<GLint>(level), 0, 0, 0,
static_cast<GLsizei>(uploadSize.x()),
static_cast<GLsizei>(uploadSize.y()),
static_cast<GLsizei>(uploadSize.z()), glFormat,
glType, uploadData);
}
break;
default:
MGLOG_E("Unhandled texture target %s",
@@ -2300,6 +2408,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
});
m_prevTextureInfo = currentTextureInfo;
// Everything dirty at entry is uploaded (or provably has no bytes to
// upload); stamp the version so per-draw re-syncs short-circuit until
// the next CPU-side mutation.
m_syncedContentVersion = stateTextureObject->GetContentVersion();
}
void BackendTextureObject::SyncBuiltinSamplerToBackend(
+13
View File
@@ -266,6 +266,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
extern StateBackendObjectRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject>
g_backendVertexArrayObjects;
// Shadowed glBindVertexArray: every backend VAO bind goes through here so a
// draw's second bind of the same VAO (SyncToBackend, then PrepareForDraw's
// re-bind) reaches the driver once. Invalidate whenever the ES context is
// replaced - ids restart and the resting binding is 0 again.
void BindBackendVAOId(Uint id);
void InvalidateVAOBindingCache();
// ES resets the binding to 0 when the currently bound VAO is deleted.
void NoteVAOIdDeleted(Uint id);
} // namespace VertexArrayImpl
namespace TextureImpl {
@@ -375,6 +384,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_imageBindableStorageRequired = false;
Bool m_backendStorageImmutable = false;
StateTextureBasicInfo m_prevTextureInfo;
// Frontend content version at the last completed mipmap sync. The per-draw
// clean probe compares this before rebuilding shape info and scanning
// per-level dirty flags; 0 never matches a real version (they start at 1).
Uint64 m_syncedContentVersion = 0;
SamplerParameters m_cacheSamplerParameters;
UintVec2 m_cacheLodRange = {0, 1000};
FloatVec4 m_cacheBorderColor = {0.0f, 0.0f, 0.0f, 0.0f};