[Fix] (Espryt): probe empty image units and keep invalid units empty on drivers that read them as zero

This commit is contained in:
2026-10-13 08:02:34 -04:00
parent d6b0eba8fe
commit 8ecab66567
7 changed files with 166 additions and 2 deletions
+9
View File
@@ -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
+1
View File
@@ -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");
@@ -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<GLenum>(view.InternalFormat);
@@ -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;
@@ -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
@@ -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<GLuint> 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<GLuint> control;
Optional<GLuint> 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<DriverBugFinding> 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<DriverBugFinding> 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,
@@ -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.