[Fix, Test] (MG_Backend/DirectGLES, MG_Util, MG_Impl): widen three-channel render targets wherever the driver refuses them

Complementary Reimagined would not load through Espryt on Mali: Iris got
GL_FRAMEBUFFER_UNSUPPORTED building its composite framebuffer, because
colortex1 is RGB8_SNORM and colortex2 is RGB16F - three-channel formats
that no real ES driver can render to (EXT_render_snorm covers R/RG/RGBA
only, and the float extensions exclude the RGB forms). The frontend's
probe cache diagnosed this correctly and then had nothing to offer: the
NoThreeChannelRenderTarget widening machinery existed but was gated to
multisample targets alone. llvmpipe turns out to refuse most of the same
attachments - CI retrace stayed green only because a replay never
branches on glCheckFramebufferStatus - so this was never a desktop-vs-
device split, just an unlit path.

The widening now applies to every color-attachable image, renderbuffers
included, riding the driver-probe branch so the native format is still
tried first and substituted only on refusal. One ThreeChannelWidening
table owns the widened (internalformat, format, type) triple per source
format - the previous per-case branches disagreed with each other and
could emit an unuploadable (RGBA16F, GL_RGB, GL_BYTE) combination or
widen into another three-channel format the driver refuses just the
same. Uploads repack three-component client data to four with the
format's own one in the alpha channel (127 is not 1 for RGB8I - the
integer arms carry integer ones); readback drops the synthetic alpha,
derived from the actual image being read, not the bound framebuffer,
so glGetTexImage through a scratch FBO cannot be confused by an
unrelated widened attachment.

Stored alpha on a widened attachment is now an invariant 1.0 rather
than an accident: the color-mask sync clears the alpha bit per draw
buffer (glColorMaski for MRT mixes), and clears route through
glClearBufferfv with alpha substituted on widened slots only -
scissored clears inherit the discipline for free, integer color
buffers keep their explicit integer-clear path, and glGet still
answers the application's own mask. GL_DST_ALPHA blending, blits and
readback therefore all see 1.0 without further interception.

DriverPost grows the rows this bug earned: EXT_color_buffer_float
detection (previously unreferenced anywhere) with a FAIL row when
absent, the missing EXT_render_snorm row, and a three-channel-
attachment row that reports one representative per widening class -
graded so a half-float-only driver warns about the 32-bit float gap
instead of being declared unsupported.

Gates: 606/606 unit at default and with the async kill switch; full
retrace, both backends - the complementary fixtures now run with the
widening ACTIVE on llvmpipe and pass with a slightly better SSIM than
before; ext caselist DirectGLES holds 3914/4867 with zero set drift
while 54 cases move from NotSupported to genuinely passing; on the
Mali-G77 device, Complementary Reimagined builds its pipeline and
renders in-world through Espryt (md5-verified build), BSL still green.
A new ThreeChannelAttachmentScenario pins the frontend answer -
COMPLETE where it used to say UNSUPPORTED - on the real driver.
This commit is contained in:
BZLZHH
2026-08-09 15:28:34 -04:00
parent 0f394fa46f
commit d8d7530011
17 changed files with 2066 additions and 143 deletions
@@ -838,6 +838,12 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_EXT_render_snorm") == 0) {
caps.SupportsRenderSnorm = true;
}
if (std::strcmp(extension, "GL_EXT_color_buffer_float") == 0) {
caps.SupportsColorBufferFloat = true;
}
if (std::strcmp(extension, "GL_EXT_color_buffer_half_float") == 0) {
caps.SupportsColorBufferHalfFloat = true;
}
if (std::strcmp(extension, "GL_EXT_sRGB_write_control") == 0) {
caps.SupportsSrgbWriteControl = true;
}
@@ -1034,6 +1034,16 @@ namespace MobileGL {
// GL_EXT_render_snorm is present, so the signed-normalized formats are colour-renderable
// (and usable as multisample texture storage) rather than texture-only.
Bool SupportsRenderSnorm = false;
// GL_EXT_color_buffer_float is present, so GL_RGBA16F / GL_RGBA32F / GL_R11F_G11F_B10F
// (and the R/RG float formats) are colour-renderable. ES 3.x core makes them
// texture-only, and every Iris shaderpack renders into at least R11F_G11F_B10F, so
// without this no shaderpack can work at all.
Bool SupportsColorBufferFloat = false;
// GL_EXT_color_buffer_half_float is present: the half-float subset of the above, for
// drivers that ship only the smaller extension. Note it does NOT rescue GL_RGB16F -
// the extension nominally lists it but disclaims it under ES 3.x, and real drivers
// reject it, which is why three-channel float attachments are widened instead.
Bool SupportsColorBufferHalfFloat = false;
// GL_EXT_sRGB_write_control is present, so GL_FRAMEBUFFER_SRGB can be turned off.
// GLES has no such switch in core: writes into an sRGB attachment are ALWAYS encoded,
// while desktop GL leaves GL_FRAMEBUFFER_SRGB disabled by default and writes raw.
+146
View File
@@ -16,7 +16,12 @@
// Only for the compile-time MAX_VERTEX_ATTRIBS constant asserted below. The POST still executes no
// MG_State code: it runs standalone, before MG_State::Init().
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
#include <MG_Backend/DirectGLES/Utils.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToStr/GLExtensionConverter.h>
#include <MG_Util/Converters/MGToStr/TextureEnumConverter.h>
#include <MG_Util/Texture/TextureFormatProcessor.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <chrono>
#include <thread>
@@ -450,6 +455,35 @@ namespace MobileGL::MG_Util::SelfTest {
builder.Warn("GL_EXT_texture_norm16",
"not supported; 16-bit normalized texture formats need emulation");
}
if (caps.SupportsRenderSnorm) {
builder.Pass("GL_EXT_render_snorm",
"supported (signed-normalized formats are colour-renderable, so an "
"SNORM render target keeps its own encoding instead of a float substitute)");
} else {
builder.Warn("GL_EXT_render_snorm",
"not supported; signed-normalized formats are texture-only, so every SNORM "
"render target is stored as a float (GL_RGBA8_SNORM/GL_RGB8_SNORM -> "
"GL_RGBA16F) and its fragment outputs are clamped to [-1,1] in software");
}
// FAIL, not WARN: ES 3.x core makes every float format texture-only, and every Iris
// shaderpack renders into at least GL_R11F_G11F_B10F (Complementary's colortex0, BSL's
// colortex0). Without this extension there is no substitute format left - a half float
// is not renderable either - so shaderpacks cannot work at all on such a driver.
if (caps.SupportsColorBufferFloat) {
builder.Pass("GL_EXT_color_buffer_float",
"supported (GL_R11F_G11F_B10F / GL_RGBA16F / GL_RGBA32F are "
"colour-renderable, which is what every shaderpack renders into)");
} else if (caps.SupportsColorBufferHalfFloat) {
builder.Warn("GL_EXT_color_buffer_float",
"not supported, but GL_EXT_color_buffer_half_float is; 16-bit float render "
"targets work, 32-bit float ones (GL_RGBA32F, and the GL_RGBA16 fallback "
"that lands on it) do not");
} else {
builder.Fail("GL_EXT_color_buffer_float",
"not supported, and neither is GL_EXT_color_buffer_half_float; no floating-point "
"format is colour-renderable on this driver, so no shaderpack can create its "
"render targets (Iris reports GL_FRAMEBUFFER_UNSUPPORTED and refuses to load)");
}
// INFO, never WARN: this is the HOST driver's ability to compile its own ESSL on
// its own threads, and MobileGL's asynchronous compilation does not depend on it
@@ -783,6 +817,117 @@ namespace MobileGL::MG_Util::SelfTest {
}
}
// No real ES driver renders to a three-channel image, but desktop GL applications ask for
// one constantly - Complementary Reimagined's colortex1 is GL_RGB8_SNORM and its colortex2
// is GL_RGB16F, and Iris refuses to load when a framebuffer built from them is not
// COMPLETE. DirectGLES substitutes the four-channel sibling, and this row names the
// outcome per format so the failure mode is a five-second read instead of an
// investigation. Answered from the capability cache that was just probed on this very
// driver, so it costs no extra GL work.
void ReportThreeChannelColorAttachments(ReportBuilder& builder, const MG_External::GLESCapabilities& caps,
const MG_Backend::FormatCapabilityCache& cache) {
// GL_RGB8 is the control: it is ES-core renderable, and it is exactly why BSL loads on
// the same driver where Complementary does not. The rest are one representative of
// each widening class - signed-normalized, half float, 32-bit float, sRGB, integer -
// so the row says which CLASS of shaderpack target a device cannot serve rather than
// just "three-channel formats".
constexpr TextureInternalFormat kProbedFormats[] = {
TextureInternalFormat::RGB8, TextureInternalFormat::RGB8Snorm, TextureInternalFormat::RGB16F,
TextureInternalFormat::RGB32F, TextureInternalFormat::SRGB8, TextureInternalFormat::RGB8UI};
const SizeT targetIndex = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D);
const Flags<PixelFormatNormalizeOptionBit> renderTargetOptions =
MG_Backend::DirectGLES::TextureImpl::GetRenderTargetNormalizeOptions(caps, targetIndex);
String nativeList;
String widenedList;
String unusableList;
// GL_RGB8 is colour-renderable in ES 3.0 CORE. A driver that answers no to it is
// broken (or the probe itself is), and that is the ONLY three-channel verdict that
// deserves a FAIL on its own - see the verdict block below.
Bool controlFormatBroken = false;
const auto append = [](String& list, const String& entry) {
if (!list.empty()) list += ", ";
list += entry;
};
for (const TextureInternalFormat probedFormat : kProbedFormats) {
const SizeT formatIndex = static_cast<SizeT>(probedFormat);
const String name = MG_Util::ConvertTextureInternalFormatToString(probedFormat);
if (MG_Backend::HasFormatCapability(cache.FullCaps[targetIndex][formatIndex],
MG_Backend::FormatCapability::FramebufferRenderable)) {
append(nativeList, name);
continue;
}
if (probedFormat == TextureInternalFormat::RGB8) {
controlFormatBroken = true;
}
if (MG_Backend::HasFormatCapability(cache.CaveatCaps[targetIndex][formatIndex],
MG_Backend::FormatCapability::FramebufferRenderable)) {
GLenum widenedInternalFormat = GL_UNKNOWN_MGL;
MG_Util::TextureFormatProcessor::NormalizePixelFormat(
MG_Util::ConvertTextureInternalFormatToGLEnum(probedFormat), renderTargetOptions,
&widenedInternalFormat, nullptr, nullptr);
append(widenedList, name + " -> " + MG_Util::ConvertGLEnumToString(widenedInternalFormat));
continue;
}
append(unusableList, name);
}
String detail;
if (!nativeList.empty()) detail += "renderable natively: " + nativeList;
if (!widenedList.empty()) {
if (!detail.empty()) detail += "; ";
detail += "widened to stay renderable: " + widenedList;
}
if (!unusableList.empty()) {
if (!detail.empty()) detail += "; ";
detail += "NOT renderable and not substitutable: " + unusableList;
}
// The verdict deliberately does NOT track "every probed format came out usable".
//
// GL_RGB32F widens to GL_RGBA32F, and GL_RGBA32F is colour-renderable only under
// GL_EXT_color_buffer_float. A perfectly healthy half-float-only driver (the common
// mobile shape: EXT_color_buffer_half_float and nothing more) therefore reports
// GL_RGB32F as unusable while every format a shaderpack actually renders into works.
// FAILing that device would make the POST's hardest verdict fire on a configuration
// MobileGL runs fine on, which is exactly how a report stops being read.
//
// So FAIL is reserved for the two answers that really are broken:
// * the ES-core control (GL_RGB8) is not renderable - the probe or the driver is
// wrong about something much more basic than three-channel widening; and
// * a widenable format has no usable fallback ON A DRIVER THAT ADVERTISES
// GL_EXT_color_buffer_float - the extension promises the widened float targets
// are renderable, so a gap here is a real, unexplained refusal.
// Everything else is a WARN carrying the exact per-format status, which is what the
// row is for. The "no float render targets at all" case is already a FAIL of its own
// on the GL_EXT_color_buffer_float row above; repeating it here would only double-count.
if (controlFormatBroken) {
builder.Fail("Three-channel colour attachments",
detail + " - GL_RGB8 is colour-renderable in OpenGL ES 3.0 core, so a driver "
"that refuses it cannot render to ANY three-channel attachment and the "
"capability probe itself is suspect");
} else if (!unusableList.empty() && caps.SupportsColorBufferFloat) {
builder.Fail("Three-channel colour attachments",
detail + " - GL_EXT_color_buffer_float is supported, so the widened "
"four-channel float targets are required to be renderable; a framebuffer "
"using one of the formats above still reports GL_FRAMEBUFFER_UNSUPPORTED, "
"which Iris turns into a hard load failure");
} else if (!unusableList.empty()) {
builder.Warn("Three-channel colour attachments",
detail + " - without GL_EXT_color_buffer_float the 32-bit float widening has no "
"renderable target left, so a shaderpack asking for one of the formats "
"above gets GL_FRAMEBUFFER_UNSUPPORTED; the half-float and fixed-point "
"ones above still work");
} else if (!widenedList.empty()) {
builder.Warn("Three-channel colour attachments",
detail + " - the substitution costs the extra alpha channel's memory and is "
"hidden from the application by an ALPHA->ONE swizzle");
} else {
builder.Pass("Three-channel colour attachments", detail);
}
}
// Everything the "MobileGL reported ..." rows need from the GLES device probe.
struct GlesProbeSummary {
Bool capsValid = false;
@@ -913,6 +1058,7 @@ namespace MobileGL::MG_Util::SelfTest {
builder.report.formatCapabilities.emplace();
MG_Backend::DirectGLES::PopulateFormatCapabilities(
glesFuncs, caps, builder.report.formatCapabilities.value());
ReportThreeChannelColorAttachments(builder, caps, builder.report.formatCapabilities.value());
} while (false);
}
@@ -58,12 +58,99 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
case GL_R8_SNORM:
applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8;
break;
// The rest of the three-channel formats no real ES driver renders to. They have no
// other fallback: none of the driver/forced option bits names them, so before the
// render-target widening existed for ordinary targets an FBO attachment in one of
// them could only ever be answered GL_FRAMEBUFFER_UNSUPPORTED (Complementary
// Reimagined's colortex2 = RGB16F).
//
// GL_RGB9_E5 is deliberately absent: its four-channel sibling would have to be a
// half float, which means unpacking the shared exponent on every transfer, and
// nothing renders to a shared-exponent format on desktop GL either.
case GL_RGB16F:
case GL_RGB32F:
case GL_SRGB8:
case GL_RGB8I:
case GL_RGB8UI:
case GL_RGB16I:
case GL_RGB16UI:
case GL_RGB32I:
case GL_RGB32UI:
applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget;
break;
default:
break;
}
return applicableOptions;
}
namespace {
// The four-channel sibling a three-channel format is widened to when the target has to
// stay colour-renderable, together with the transfer pair that describes client data for
// it. Kept in one place because all three of NormalizePixelFormat's switches have to agree:
// reporting the widened storage but the original three-channel base format emitted
// inconsistent triples such as (GL_RGBA16F, GL_RGB, GL_BYTE), which is
// GL_INVALID_OPERATION for glTexImage2D on ES. That only ever went unnoticed because the
// bit was reachable for multisample storage alone, and glTexStorage*Multisample takes no
// transfer pair at all.
struct ThreeChannelWidening {
GLenum InternalFormat = GL_UNKNOWN_MGL;
GLenum Format = GL_UNKNOWN_MGL;
GLenum Type = GL_UNKNOWN_MGL;
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
ThreeChannelWidening GetThreeChannelRenderTargetWidening(GLenum internalFormat,
Flags<PixelFormatNormalizeOptionBit> options) {
if (!(options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget)) {
return {};
}
switch (internalFormat) {
// Signed-normalized: matches what the always-on NoRGBA8Snorm fallback already does to
// GL_RGBA8_SNORM, so the two SNORM8 formats land on the same storage.
case GL_RGB8_SNORM:
return {GL_RGBA16F, GL_RGBA, GL_FLOAT};
case GL_RGB16_SNORM:
// A half float loses the low bits of a 16-bit SNORM channel, so keep the
// signed-normalized encoding whenever the driver can render to it.
return (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget)
? ThreeChannelWidening{GL_RGBA16F, GL_RGBA, GL_FLOAT}
: ThreeChannelWidening{GL_RGBA16_SNORM, GL_RGBA, GL_SHORT};
// Unsigned-normalized 16-bit (and the legacy 10/12-bit formats stored as RGB16):
// GL_RGB32F is a legal ES texture format but is not colour-renderable either.
case GL_RGB16:
case GL_RGB10:
case GL_RGB12:
return {GL_RGBA32F, GL_RGBA, GL_FLOAT};
// Floating point.
case GL_RGB16F:
return {GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT};
case GL_RGB32F:
return {GL_RGBA32F, GL_RGBA, GL_FLOAT};
// sRGB: GL_SRGB8_ALPHA8 keeps the sRGB encoding of the colour channels and stores
// the added alpha linearly, which is exactly the three-channel format's semantics.
case GL_SRGB8:
return {GL_SRGB8_ALPHA8, GL_RGBA, GL_UNSIGNED_BYTE};
// Integer.
case GL_RGB8I:
return {GL_RGBA8I, GL_RGBA_INTEGER, GL_BYTE};
case GL_RGB8UI:
return {GL_RGBA8UI, GL_RGBA_INTEGER, GL_UNSIGNED_BYTE};
case GL_RGB16I:
return {GL_RGBA16I, GL_RGBA_INTEGER, GL_SHORT};
case GL_RGB16UI:
return {GL_RGBA16UI, GL_RGBA_INTEGER, GL_UNSIGNED_SHORT};
case GL_RGB32I:
return {GL_RGBA32I, GL_RGBA_INTEGER, GL_INT};
case GL_RGB32UI:
return {GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT};
default:
return {};
}
}
} // namespace
void NormalizePixelFormat(GLenum internalFormat, Flags<PixelFormatNormalizeOptionBit> options,
GLenum* outInternalFormat, GLenum* outFormat, GLenum* outType) {
#ifdef TRACY_ENABLE
@@ -95,13 +182,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat;
break;
case GL_RGB16:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
// GL_RGB32F is a legal ES texture format but is not colour-renderable, so
// glTexStorage2DMultisample rejects it and the attachment ends up with no
// storage at all.
*outInternalFormat = GL_RGBA32F;
break;
}
if ((options & PixelFormatNormalizeOptionBit::NoNorm16) ||
(options & PixelFormatNormalizeOptionBit::NoRgb16)) {
*outInternalFormat = GL_RGB32F;
@@ -132,14 +212,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat;
break;
case GL_RGB16_SNORM:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
// A half float loses the low bits of a 16-bit SNORM channel, so keep the
// signed-normalized encoding whenever the driver can render to it.
*outInternalFormat = (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget)
? GL_RGBA16F
: GL_RGBA16_SNORM;
break;
}
if ((options & PixelFormatNormalizeOptionBit::NoNorm16) ||
(options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) ||
(options & PixelFormatNormalizeOptionBit::NoSnorm16)) {
@@ -173,10 +245,6 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
*outInternalFormat = internalFormat;
break;
case GL_RGB8_SNORM:
if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) {
*outInternalFormat = GL_RGBA16F;
break;
}
if (options & PixelFormatNormalizeOptionBit::NoSnorm8) {
*outInternalFormat = GL_RGB16F;
break;
@@ -615,5 +683,18 @@ namespace MobileGL::MG_Util::TextureFormatProcessor {
break;
}
}
// Applied last, over whatever the three switches above chose: widening a three-channel
// format to keep a colour attachment renderable outranks every other fallback, because
// the others all pick a three-channel storage the driver still refuses to render to
// (GL_RGB8_SNORM -> GL_RGB16F under NoSnorm8, GL_RGB16 -> GL_RGB32F under NoNorm16).
// All three outputs move together: reporting the widened storage while leaving the
// three-channel base format and its component type in place produced triples like
// (GL_RGBA16F, GL_RGB, GL_BYTE), which ES rejects for glTexImage2D outright.
if (const ThreeChannelWidening widening = GetThreeChannelRenderTargetWidening(internalFormat, options)) {
if (outInternalFormat) *outInternalFormat = widening.InternalFormat;
if (outFormat) *outFormat = widening.Format;
if (outType) *outType = widening.Type;
}
}
} // namespace MobileGL::MG_Util::TextureFormatProcessor
@@ -20,9 +20,14 @@ namespace MobileGL {
NoRGB16Snorm = 1 << 6,
// The target must be colour-renderable and ES has no renderable three-channel
// form of the requested format, so it has to be widened to the four-channel one.
// Only meaningful for multisample textures: those can never be uploaded to, only
// rendered into, so the extra alpha comes from the draw (1.0 for an RGB source)
// and no transfer path has to expand three-channel client data.
// Set for any colour-attachable target whose native three-channel form the driver
// refused to render to (multisample storage always, since ES has no three-channel
// multisample format at all; every other target only after its native probe failed).
// The widening is visible to every transfer path, so it also retargets the (format,
// type) pair NormalizePixelFormat reports: the upload has to describe four
// components in the widened storage's component type, the backend has to expand
// three-channel client data with an alpha of 1.0, and sampling/readback has to hide
// the added alpha again (BackendTextureFormatAddsAlpha).
NoThreeChannelRenderTarget = 1 << 7,
// Pairs with the bit above: the widened four-channel format has to stay renderable AND
// keep 16-bit signed-normalized precision, which needs both EXT_texture_norm16 and