[Fix] (DirectGLES): flush queued buffer ranges through a range-invalidating map first, so a partial write into a busy store is priced by the range

This commit is contained in:
2026-08-27 23:54:19 -04:00
parent 734fab9f90
commit 29599dcf90
3 changed files with 48 additions and 10 deletions
+8
View File
@@ -176,6 +176,14 @@ namespace MobileGL::MG_Config {
// upload ring (negative control / driver-bug escape hatch; the immediate
// upload stalls on drivers that resolve the WAR hazard on the CPU, e.g. Mali).
Bool DisableUploadRing = false;
// MOBILEGL_DISABLE_INVALIDATE_FLUSH: skip the glMapBufferRange(WRITE |
// INVALIDATE_RANGE) tier of the DirectGLES pending-range flush and go straight
// to the upload ring's staged glCopyBufferSubData (negative control / escape
// hatch for a driver whose range-invalidating map misbehaves). The map tier is
// what keeps a partial write into a large in-flight buffer priced by the RANGE:
// on Mali both the immediate glBufferSubData and a staged copy into a busy
// mutable store ghost the whole destination on the CPU.
Bool DisableInvalidateFlush = false;
// MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES
// depth/stencil reads and always go through the shader-sampling emulation. Core GL
// ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does,
+1
View File
@@ -186,6 +186,7 @@ namespace MobileGL::MG_ConfigLoader {
features.DisableUboRing = QueryEnvFlag("MOBILEGL_DISABLE_UBO_RING");
features.DisableUnpackRing = QueryEnvFlag("MOBILEGL_DISABLE_UNPACK_RING");
features.DisableUploadRing = QueryEnvFlag("MOBILEGL_DISABLE_UPLOAD_RING");
features.DisableInvalidateFlush = QueryEnvFlag("MOBILEGL_DISABLE_INVALIDATE_FLUSH");
features.EsprytForceDepthStencilReadbackEmulation =
QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION");
features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS");
+39 -10
View File
@@ -829,10 +829,32 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
// Push every queued range of `resource` from the shadow into the backend
// store. Ranges go through the staging ring + glCopyBufferSubData when the
// ring is usable - the copy resolves the WAR hazard against in-flight
// frames on the GPU timeline, where it is mere job ordering - and fall
// back to the direct (potentially stalling) glBufferSubData otherwise.
// store, without ever letting a driver resolve the WAR hazard against
// in-flight frames at the WHOLE BUFFER's expense. Three tiers:
//
// 1. glMapBufferRange(WRITE | INVALIDATE_RANGE) + memcpy. The entire
// mapped range is rewritten from the authoritative shadow, so
// declaring its old bytes dead is exact - and it lets the driver
// swap fresh pages in for JUST that range. This is the only tier
// whose cost scales with the RANGE on this Mali driver: both the
// immediate glBufferSubData (pre-queueing) and a staged
// glCopyBufferSubData into a busy MUTABLE store ghost the whole
// destination with a worker-thread memcpy - Minecraft 26.3 streams
// ~1MB section meshes into 128MB arenas about nine times a frame
// during a camera pan, and 9 x 128MB of ghosting per frame is
// ~380ms, the measured 2-4 fps. (Backing the arenas with immutable
// stores also kills the ghost, but eagerly commits every arena's
// full extent - +hundreds of MB - which LMK'd the whole device.)
// 2. The staging ring + glCopyBufferSubData: the copy is ordered on
// the GPU timeline, no CPU wait (MOBILEGL_DISABLE_INVALIDATE_FLUSH
// forces this tier as the map path's negative control).
// 3. Direct glBufferSubData (potentially stalling) when neither the
// map entry points nor the ring exist.
//
// The ranges are flushed AS QUEUED (VecRange1D::Add already merges
// near-adjacent ones): bytes, not flush calls, are the cost axis here,
// and collapsing a scattered flush into its union re-copied nearly whole
// chunk-mesh arenas every frame.
// The caller owns syncedChangeSerial; this only drains the queue.
void FlushPendingRangesNow(GLESBufferResource& resource, BufferObject& bufferObject) {
#ifdef TRACY_ENABLE
@@ -848,19 +870,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Clamp against BOTH extents: the readback flush may run while the
// frontend size and the backend store disagree (a pending respecify
// resolves that later; bytes past either end have nowhere to land).
//
// The ranges are copied AS QUEUED (VecRange1D::Add already merges
// near-adjacent ones): this driver runs buffer copies as worker-thread
// memcpys, so BYTES are the cost axis - collapsing a scattered flush
// into its union re-copied nearly whole chunk-mesh arenas every frame
// and saturated the copy worker during camera pans.
const SizeT limit = std::min(bufferObject.GetSize(), resource.storageSize);
const Bool mapUsable = !MG_Config::Features.DisableInvalidateFlush &&
g_GLESFuncs.glMapBufferRange && g_GLESFuncs.glUnmapBuffer;
const Bool ringUsable = UploadRingUsableNow();
for (const auto& range : ranges) {
const SizeT end = std::min(range.end, limit);
const SizeT start = std::min(range.start, end);
const SizeT size = end - start;
if (size == 0) continue;
if (mapUsable) {
BindBufferId(TempBufferTarget, resource.id);
void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start,
(GLsizeiptr)size,
GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
if (dst) {
Memcpy(dst, bufferObject.MappedData() + start, size);
g_GLESFuncs.glUnmapBuffer(TempBufferTarget);
continue;
}
}
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {