From 8ecab665673479f7e5e2c92aa2d219d69f2042f5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 13 Oct 2026 08:02:34 -0400 Subject: [PATCH] [Fix] (Espryt): probe empty image units and keep invalid units empty on drivers that read them as zero --- MobileGL/Config.h | 9 ++ MobileGL/ConfigLoader.cpp | 1 + MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 10 +- .../MG_Util/BackendLoaders/OpenGL/Loader.cpp | 17 +++ .../MG_Util/BackendLoaders/OpenGL/Loader.h | 5 + MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp | 115 ++++++++++++++++++ MobileGL/MG_Util/SelfTest/DriverBugProbes.h | 11 ++ 7 files changed, 166 insertions(+), 2 deletions(-) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index e5ff8398..f83d9c51 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -214,6 +214,15 @@ namespace MobileGL::MG_Config { // right everywhere. ForceOn exercises the atomic reads on a healthy driver; ForceOff is // the negative control. QuirkOverride EsprytAtomicSubgroupScratch = QuirkOverride::Auto; + // MOBILEGL_ESPRYT_INVALID_IMAGE_PLACEHOLDER: DirectGLES binds an image unit GL 4.6 8.26 + // calls invalid (a level the texture does not have) to a zeroed 1x1 texture of the bind's + // shape instead of leaving it empty. The Adreno ES driver answers a load through an EMPTY + // unit with alpha 1 where GL says 0; the driver POST measures that and arms this by + // itself, so Auto is right everywhere. The placeholder's one texel is real, so a store + // through such a unit can be read back through the same unit in the same pass - which is + // why a healthy driver keeps the empty unit. ForceOn exercises the placeholder on a + // healthy driver; ForceOff is the negative control. + QuirkOverride EsprytInvalidImagePlaceholder = QuirkOverride::Auto; // MOBILEGL_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry // stages into an ordinary varying (ShaderCompiler:: // DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 10737c99..1c190513 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -400,6 +400,7 @@ namespace MobileGL::MG_ConfigLoader { features.EsprytUnlocatedIoBlocks = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS"); features.EsprytDeferGeometryEmits = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS"); features.EsprytAtomicSubgroupScratch = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_ATOMIC_SUBGROUP_SCRATCH"); + features.EsprytInvalidImagePlaceholder = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_INVALID_IMAGE_PLACEHOLDER"); features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION"); features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH"); features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY"); diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 738bb0e4..dcd4f9d9 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -2870,8 +2870,10 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } - // What such a unit is bound to instead. An EMPTY ES unit is not enough: ES 3.2 8.22 leaves - // an invalid load's alpha undefined, and Adreno answers 1 where GL says 0. A texel outside + // What such a unit is bound to instead, on a driver whose EMPTY unit is not enough + // (GLESCapabilities::EmptyImageUnitsReadNonzero): ES 3.2 8.22 leaves an invalid load's + // alpha undefined, and Adreno answers 1 where GL says 0. Elsewhere the unit stays empty, + // which is exact. A texel outside // a level is invalid too, and that Adreno answers (0, 0, 0, 0) with the store dropped - so // the unit gets a 1x1 texture of the shape and format the bind names, cleared to zero each // time it is bound. Only texel (0, 0[, 0]) of it exists; it is private to the unit, so a @@ -2988,6 +2990,10 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } if (ImageViewNamesNoTexel(view)) { + if (!g_GLESCapabilities.EmptyImageUnitsReadNonzero) { + g_GLESFuncs.glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + return; + } // The format the shader was widened from is the one the application bound, so // the placeholder takes that format's carrier, as the storage would have. GLenum format = static_cast(view.InternalFormat); diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 8d581347..3158aae8 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -2125,6 +2125,23 @@ namespace MobileGL::MG_Util::BackendLoader { ? "true (the subgroup emulation reads its scratch through atomic loads)" : "false"); + switch (MG_Config::Features.EsprytInvalidImagePlaceholder) { + case MG_Config::QuirkOverride::ForceOn: + caps.EmptyImageUnitsReadNonzero = true; + break; + case MG_Config::QuirkOverride::ForceOff: + caps.EmptyImageUnitsReadNonzero = false; + break; + case MG_Config::QuirkOverride::Auto: + default: + caps.EmptyImageUnitsReadNonzero = SelfTest::EmptyImageUnitsReadNonzero(glesFuncs); + break; + } + MGLOG_I(" Empty image units read nonzero: %s", + caps.EmptyImageUnitsReadNonzero + ? "true (an invalid image unit is bound to a zeroed placeholder)" + : "false"); + caps.IsAngleRenderer = caps.GLESRendererString.find("ANGLE") != String::npos; caps.IsAngleLlvmpipeRenderer = caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos; diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index db8ac0df..6fe3bf7b 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1329,6 +1329,11 @@ namespace MobileGL { // Adreno). The subgroup emulation then reads its scratch through atomic loads. // Defaults FALSE: never assumed broken on an unmeasured driver. Bool SharedMemoryLoadsGoStale = false; + // True when a load through an EMPTY image unit returns a nonzero texel + // (SelfTest::EmptyImageUnitsReadNonzero - measured on Adreno: alpha 1). DirectGLES + // then binds an invalid unit to a zeroed placeholder instead of leaving it empty. + // Defaults FALSE: never assumed broken on an unmeasured driver. + Bool EmptyImageUnitsReadNonzero = false; Int UniformBufferOffsetAlignment = 256; // Its storage-buffer counterpart, queried separately because it is a separate limit: // Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp index 3099431d..9a540d46 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp @@ -530,6 +530,104 @@ namespace MobileGL::MG_Util::SelfTest { return stale; } + namespace { + constexpr const char* kEmptyImageProbeName = "empty image unit load"; + constexpr GLuint kEmptyImageSentinel = 0xDEADBEEFu; + + // Loads texel (0, 0) of the rgba8 image on kProbeImageUnit and writes it packed. + String EmptyImageComputeSource() { + return "#version 310 es\n" + "layout(local_size_x = 1) in;\n" + "layout(rgba8, binding = " + std::to_string(kProbeImageUnit) + + ") readonly uniform highp image2D u_image;\n" + "layout(std430, binding = 0) buffer Result { highp uint data[4]; } g_result;\n" + "void main() { g_result.data[0] = packUnorm4x8(imageLoad(u_image, ivec2(0))); }\n"; + } + + // Runs the program with `texture` (0 = empty) on the probe unit; nullopt when the + // dispatch failed or the shader never wrote its word. + Optional RunEmptyImageVariant(const GLESFunctionsTable& gl, GLuint program, GLuint buffer, + GLuint texture) { + const GLuint init[4] = {kEmptyImageSentinel, 0u, 0u, 0u}; + gl.glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + gl.glBufferData(GL_SHADER_STORAGE_BUFFER, kProbeSize, init, GL_DYNAMIC_DRAW); + gl.glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer); + gl.glBindImageTexture(kProbeImageUnit, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + gl.glUseProgram(program); + Drain(gl); + gl.glDispatchCompute(1, 1, 1); + if (gl.glGetError() != GL_NO_ERROR) return std::nullopt; + gl.glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_BUFFER_UPDATE_BARRIER_BIT); + gl.glFinish(); + const GLuint value = ReadFirstWord(gl, buffer); + if (value == kEmptyImageSentinel) return std::nullopt; + return value; + } + } // namespace + + Bool ProbeEmptyImageUnitsReadNonzero(const GLESFunctionsTable& gl) { + if (!HasEveryEntryPoint(gl) || gl.glDispatchCompute == nullptr || gl.glBindImageTexture == nullptr || + gl.glTexStorage2D == nullptr || gl.glTexSubImage2D == nullptr) { + return false; + } + + SavedState saved; + Save(gl, saved); + Drain(gl); + + const String source = EmptyImageComputeSource(); + const GLuint shader = CompileStage(gl, GL_COMPUTE_SHADER, source.c_str(), "compute", kEmptyImageProbeName); + GLuint program = 0; + GLuint buffer = 0; + GLuint texture = 0; + Optional control; + Optional empty; + if (shader != 0 && (program = gl.glCreateProgram()) != 0) { + gl.glAttachShader(program, shader); + gl.glLinkProgram(program); + GLint linked = 0; + gl.glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked != GL_FALSE) { + gl.glGenBuffers(1, &buffer); + gl.glGenTextures(1, &texture); + gl.glActiveTexture(GL_TEXTURE0); + gl.glBindTexture(GL_TEXTURE_2D, texture); + gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 1, 1); + gl.glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + const GLubyte zero[4] = {0, 0, 0, 0}; + gl.glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, zero); + if (gl.glGetError() == GL_NO_ERROR) { + control = RunEmptyImageVariant(gl, program, buffer, texture); + empty = RunEmptyImageVariant(gl, program, buffer, 0); + } + } + } + if (texture != 0) gl.glDeleteTextures(1, &texture); + if (buffer != 0) gl.glDeleteBuffers(1, &buffer); + if (program != 0) gl.glDeleteProgram(program); + if (shader != 0) gl.glDeleteShader(shader); + Restore(gl, saved); + + if (!control || !empty) { + MGLOG_I("[driver-bug] %s probe reached no verdict (a variant did not build or run)", kEmptyImageProbeName); + return false; + } + if (*control != 0u) { + MGLOG_I("[driver-bug] %s probe reached no verdict: the zeroed control read 0x%08x", kEmptyImageProbeName, + *control); + return false; + } + const Bool nonzero = *empty != 0u; + MGLOG_I("[driver-bug] %s probe: control reads 0x%08x, empty unit reads 0x%08x (expected 0)%s", + kEmptyImageProbeName, *control, *empty, nonzero ? " - EMPTY IMAGE UNITS READ NONZERO" : ""); + return nonzero; + } + + Bool EmptyImageUnitsReadNonzero(const GLESFunctionsTable& gl) { + static const Bool nonzero = ProbeEmptyImageUnitsReadNonzero(gl); + return nonzero; + } + namespace { // ===================== SHARED PROGRAM BUILDING ===================== // The four probes below all decide their verdict from whether a program LINKS or from @@ -2421,6 +2519,22 @@ namespace MobileGL::MG_Util::SelfTest { Move(detail)}; } + Optional ProbeEmptyImageUnitBug(const GLESFunctionsTable& gl) { + if (!EmptyImageUnitsReadNonzero(gl)) return std::nullopt; + String detail = "a load through an image unit with no texture bound returns alpha 1 where GL 4.6 8.26 " + "says every channel is 0. "; + if (MG_Config::Features.EsprytInvalidImagePlaceholder == MG_Config::QuirkOverride::ForceOff) { + detail += "THE REPAIR IS DISABLED in this process (MOBILEGL_ESPRYT_INVALID_IMAGE_PLACEHOLDER), so " + "a load through an invalid image unit reads alpha 1"; + return DriverBugFinding{"Empty image units read nonzero", DriverBugVerdict::Unfixable, Move(detail)}; + } + detail += "DirectGLES binds an image unit whose texture lacks the bound level to a zeroed 1x1 texture " + "of the bind's shape, so its loads read 0 (a store through that unit can still be read back " + "through the same unit at texel 0 within one pass); a unit the application left empty stays " + "empty; override with MOBILEGL_ESPRYT_INVALID_IMAGE_PLACEHOLDER"; + return DriverBugFinding{"Empty image units read nonzero", DriverBugVerdict::Fixed, Move(detail)}; + } + Optional ProbeR32FMultisampleSwizzleBug(const GLESFunctionsTable& gl) { if (!R32FMultisampleSwizzleCorrupted(gl)) return std::nullopt; return DriverBugFinding{ @@ -2594,6 +2708,7 @@ namespace MobileGL::MG_Util::SelfTest { constexpr DriverBugProbeFn kGlesDriverBugProbes[] = { &ProbeGeometryWriteAfterEmitBug, &ProbeStaleSharedMemoryLoadBug, + &ProbeEmptyImageUnitBug, &ProbeR32FMultisampleSwizzleBug, &ProbeImageLocationPerNameBug, &ProbeCrossStageImageQualifierMergeBug, diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h index 6c82396d..5e870296 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h @@ -198,6 +198,17 @@ namespace MobileGL::MG_Util::SelfTest { // ProbeSharedMemoryLoadsGoStale(), evaluated at most once per process. Bool SharedMemoryLoadsGoStale(const MG_External::GLESFunctionsTable& gl); + // A compute shader loads texel (0, 0) of an rgba8 image unit twice: once with a zeroed 1x1 + // texture bound (the control, which must read 0) and once with the unit EMPTY (the subject). + // GL 4.6 8.26 makes the empty load return zero; Adreno returns alpha 1 (0xff000000). True + // only when the subject reads nonzero while the control reads zero. Deterministic, so + // latched; false when compute shaders or an entry point are missing or anything fails to + // set up. Restores the GL state it touches. + Bool ProbeEmptyImageUnitsReadNonzero(const MG_External::GLESFunctionsTable& gl); + + // ProbeEmptyImageUnitsReadNonzero(), evaluated at most once per process. + Bool EmptyImageUnitsReadNonzero(const MG_External::GLESFunctionsTable& gl); + // Samples one R32F GL_TEXTURE_2D_MULTISAMPLE texel through a swizzled alpha channel, twice, // with a separately linked program each time. Returns true only when the swizzled read goes // wrong while every control read stays right.