diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index 3195b9f4..d9aec60c 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -213,7 +213,10 @@ namespace MobileGL::MG_Backend::DirectGLES { if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { reasons.push_back("no colour-renderable three-channel format on OpenGL ES"); } - if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { + // A format is either 8- or 16-bit signed normalized, so at most one of the two ever + // survives GetApplicablePixelFormatNormalizeOptions and the reason is not duplicated. + if ((options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { reasons.push_back("EXT_render_snorm not supported"); } diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 3abb7640..b262539b 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -171,6 +171,12 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!capabilities.SupportsRenderSnorm || !capabilities.SupportsNorm16Texture) { options |= PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; } + // 8-bit signed-normalized storage is core ES, so only the rendering half is in + // question here; the 16-bit bit above additionally needs EXT_texture_norm16 for the + // encoding to exist at all. + if (!capabilities.SupportsRenderSnorm) { + options |= PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget; + } return options; } diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index d7d201fd..fc8d89c2 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -59,6 +59,7 @@ add_executable(MobileGLIntegrationTest Scenarios/AsyncCompileScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp Scenarios/ThreeChannelAttachmentScenario.cpp + Scenarios/SnormAttachmentScenario.cpp Scenarios/PipelineFailureScenario.cpp Scenarios/AdvertisedLimitsScenario.cpp Scenarios/PixelStoreSweepScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SnormAttachmentScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SnormAttachmentScenario.cpp new file mode 100644 index 00000000..c02345c0 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/SnormAttachmentScenario.cpp @@ -0,0 +1,245 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SnormAttachmentScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - SIGNED-NORMALIZED COLOUR ATTACHMENTS, on a live driver. +// +// The bug: a GLES driver without GL_EXT_render_snorm treats every signed-normalized format as +// texture-only. DirectGLES had a colour-renderable substitute for exactly one of the eight +// (GL_RGB16_SNORM, through the three-channel widening), so an R8_SNORM or R16_SNORM attachment got +// no storage the driver would render into: the ES framebuffer was incomplete, the draw landed +// nowhere, and glGetTexImage fell through to the CPU shadow - all zeroes for a texture created with +// no data. KHR-GL4x.texture_swizzle renders into a SINGLE-CHANNEL SNORM output for every one of its +// SNORM source formats, which is why all 46 of its GL43 SNORM cases failed on Mali. +// +// THE OTHER HALF, and the reason this scenario asserts VALUES rather than only completeness: the +// substitute has to be exact. A half float's 11-bit mantissa cannot represent a 16-bit SNORM +// channel - 23451/32767 quantizes about six SNORM steps away, against a conformance window of one - +// so the 16-bit formats must land on a 32-bit float even though the 8-bit ones are fine in a half. +// Trading 46 visible failures for silent precision loss in Iris' SNORM normal buffers would be the +// worse outcome, so the round trip below is pinned tightly enough to fail on a half-float substitute +// (tolerance two SNORM steps, half-float error six). +// +// WHAT THIS GATE CAN AND CANNOT SEE. Both CI drivers (Mesa llvmpipe) and Adreno expose +// GL_EXT_render_snorm, so they take the NATIVE path here and the substitution stays dead. That is +// precisely why the assertions are written as invariants of the format rather than of the fallback: +// "a signed-normalized colour attachment is complete and round-trips its channel values" has to +// hold whichever path answers it, so the scenario fails if anyone ever routes these formats to a +// lossy storage on a driver where it IS live. The substitution itself can only be observed on a +// device without EXT_render_snorm (Mali Immortalis-G925). +// +// DirectGLES only, like the three-channel scenario next door: DirectVulkan resolves SNORM formats +// on its own terms and asserting Espryt's answers there would pin a coincidence. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + // A uniform rather than a literal so nothing can constant-fold the value into a different + // precision than the one the attachment stores. + constexpr const char* kFS = R"(#version 330 core +out vec4 oColor; +uniform float uValue; +void main() { oColor = vec4(uValue, 0.0, 0.0, 1.0); } +)"; + + constexpr int kSize = 8; + + // The two channel values the round trip is pinned on. Both are positive on purpose: + // glReadPixels applies GL_CLAMP_READ_COLOR (GL_FIXED_ONLY by default) to a fixed-point + // colour buffer, so the negative half of a SNORM attachment reads back as 0 and would + // measure the clamp instead of the storage. + constexpr int kSnorm8Value = 99; + constexpr int kSnorm16Value = 23451; + + class SnormAttachmentScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + if (Gl().BackendName() != "DirectGLES") { + GTEST_SKIP() << "the signed-normalized substitution is a DirectGLES fallback; backend is " + << Gl().BackendName(); + } + } + + // A single-level 2D texture in `internalFormat`, or 0 when the driver rejects the + // storage outright (which is a different failure from rejecting the ATTACHMENT). + static GLuint MakeTexture(GLenum internalFormat) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kSize, kSize); + if (glGetError() != GL_NO_ERROR) { + glDeleteTextures(1, &texture); + return 0; + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glBindTexture(GL_TEXTURE_2D, 0); + return texture; + } + + static GLenum SingleAttachmentStatus(GLenum internalFormat) { + const GLuint texture = MakeTexture(internalFormat); + if (texture == 0) return GL_NONE; + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + const GLenum status = glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + return status; + } + + // Renders `value` into the red channel of a fresh `internalFormat` attachment and hands + // back what glReadPixels sees. Returns false when the framebuffer never came up, which + // is the failure mode this scenario exists for - a draw into an incomplete framebuffer + // is dropped by the driver and leaves the caller reading the cleared texture. + bool RenderAndReadRed(GLenum internalFormat, float value, float* outRed) { + std::string error; + const GLuint program = CompileProgram(kVS, kFS, &error); + EXPECT_NE(program, 0u) << error; + if (program == 0) return false; + const GLint valueLocation = glGetUniformLocation(program, "uValue"); + EXPECT_GE(valueLocation, 0); + + const GLuint texture = MakeTexture(internalFormat); + EXPECT_NE(texture, 0u) << "the driver refused the texture storage itself"; + if (texture == 0) { + glDeleteProgram(program); + return false; + } + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + const bool complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + + if (complete) { + const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + GLuint vao = 0; + GLuint vbo = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glUniform1f(valueLocation, value); + glViewport(0, 0, kSize, kSize); + // Cleared to zero so a dropped draw cannot be mistaken for a correct one. + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + std::vector pixels(static_cast(kSize) * kSize * 4, -1.0f); + glReadBuffer(GL_COLOR_ATTACHMENT0); + glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_FLOAT, pixels.data()); + if (outRed) *outRed = pixels[0]; + + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &texture); + glDeleteProgram(program); + return complete; + } + }; + + // THE regression gate for the frontend's answer. Every one of these used to be + // GL_FRAMEBUFFER_UNSUPPORTED on a driver without EXT_render_snorm, and nothing in the CTS + // (or in Iris) checks the status before drawing, so the failure was silent all the way to a + // readback of zeroes. + TEST_F(SnormAttachmentScenario, SignedNormalizedColorAttachmentsReportComplete) { + if (!Ready() || IsSkipped()) return; + + // GL_R8 is the control: colour-renderable in ES core, so it must pass with or without + // any substitution. If it ever fails, nothing below means anything. + EXPECT_EQ(SingleAttachmentStatus(GL_R8), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "GL_R8 is ES-core colour-renderable"; + + // The single-channel pair KHR-GL4x.texture_swizzle renders into for every SNORM source + // format - the whole 46-case failure. + EXPECT_EQ(SingleAttachmentStatus(GL_R8_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(SingleAttachmentStatus(GL_R16_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + // ...and the two- and four-channel siblings, which are what a shaderpack actually + // declares (Iris colortex buffers in RGBA16_SNORM). + EXPECT_EQ(SingleAttachmentStatus(GL_RG8_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(SingleAttachmentStatus(GL_RG16_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(SingleAttachmentStatus(GL_RGBA8_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + EXPECT_EQ(SingleAttachmentStatus(GL_RGBA16_SNORM), static_cast(GL_FRAMEBUFFER_COMPLETE)); + + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + // The other half: whatever storage answers for the attachment has to hold the channel value + // to the format's own precision. This is the assertion that fails if the 16-bit formats are + // ever routed to a half float - the substitute an implementer naturally reaches for, because + // it is what the 8-bit ones correctly use. + TEST_F(SnormAttachmentScenario, SignedNormalizedAttachmentsRoundTripTheirChannelValues) { + if (!Ready() || IsSkipped()) return; + + const float snorm8Expected = static_cast(kSnorm8Value) / 127.0f; + float red8 = -1.0f; + ASSERT_TRUE(RenderAndReadRed(GL_R8_SNORM, snorm8Expected, &red8)) + << "an R8_SNORM colour attachment must be complete before any value can be asserted"; + // Two 8-bit SNORM steps. A half float is exact here (worst case 0.03 of a step), so this + // only has to catch a storage that quantizes harder than the format itself. + EXPECT_NEAR(red8, snorm8Expected, 2.0f / 127.0f) + << "R8_SNORM attachment lost its channel value"; + EXPECT_GT(red8, 0.5f) << "the draw never landed - this is the cleared texture, not the rendered one"; + + const float snorm16Expected = static_cast(kSnorm16Value) / 32767.0f; + float red16 = -1.0f; + ASSERT_TRUE(RenderAndReadRed(GL_R16_SNORM, snorm16Expected, &red16)) + << "an R16_SNORM colour attachment must be complete before any value can be asserted"; + // Two 16-bit SNORM steps (6.1e-5). A half float would land 1.9e-4 away - three times + // this window - which is exactly the failure this bound exists to catch. + EXPECT_NEAR(red16, snorm16Expected, 2.0f / 32767.0f) + << "R16_SNORM attachment was stored in something that cannot hold 16 signed bits"; + EXPECT_GT(red16, 0.5f) << "the draw never landed - this is the cleared texture, not the rendered one"; + + float red16x4 = -1.0f; + ASSERT_TRUE(RenderAndReadRed(GL_RGBA16_SNORM, snorm16Expected, &red16x4)) + << "an RGBA16_SNORM colour attachment must be complete before any value can be asserted"; + EXPECT_NEAR(red16x4, snorm16Expected, 2.0f / 32767.0f) + << "RGBA16_SNORM attachment was stored in something that cannot hold 16 signed bits"; + + EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError()); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index 7d461c47..0c17236d 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -3868,6 +3868,131 @@ TEST_F(TextureTest, ColorAttachableTargetsRequestTheThreeChannelWidening) { PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, texture2DIndex) & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); + + // ...and neither can an 8-bit one. That half of the answer used to be missing entirely, which + // is why an R8_SNORM / RG8_SNORM colour attachment got no substitute at all on a driver + // without EXT_render_snorm. + EXPECT_TRUE(GetRenderTargetNormalizeOptions(noSnormCapabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget); + EXPECT_FALSE(GetRenderTargetNormalizeOptions(capabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget); + EXPECT_FALSE(GetRenderTargetNormalizeOptions(noSnormCapabilities, bufferIndex) & + PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget); + + // 8-bit signed-normalized storage is core ES, so only EXT_render_snorm gates the 8-bit bit; + // the 16-bit one also needs EXT_texture_norm16 for the encoding to exist at all. + MG_External::GLESCapabilities noNorm16Capabilities{}; + noNorm16Capabilities.SupportsRenderSnorm = true; + noNorm16Capabilities.SupportsNorm16Texture = false; + EXPECT_TRUE(GetRenderTargetNormalizeOptions(noNorm16Capabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); + EXPECT_FALSE(GetRenderTargetNormalizeOptions(noNorm16Capabilities, texture2DIndex) & + PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget); +} + +// ---- Signed-normalized colour-renderable substitution (KHR-GL4x.texture_swizzle on Mali) ------- +// +// A driver without GL_EXT_render_snorm treats every signed-normalized format as texture-only, so a +// colour attachment in one of them leaves the ES framebuffer incomplete: the draw lands nowhere and +// the readback falls through to the CPU shadow, which for a glTexImage2D(..., nullptr) output +// texture is all zeroes. The render-target bits used to reach GL_RGB16_SNORM alone, so five of the +// eight SNORM formats - and in particular the single-channel GL_R8_SNORM / GL_R16_SNORM that +// KHR-GL4x.texture_swizzle renders into for EVERY SNORM source format - had no fallback at all. + +TEST_F(TextureTest, SnormRenderTargetOptionsApplyToEverySignedNormalizedFormat) { + using MG_Util::TextureFormatProcessor::GetApplicablePixelFormatNormalizeOptions; + const Flags requested = + PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget | PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; + + for (const GLenum internalFormat : {GL_R8_SNORM, GL_RG8_SNORM, GL_RGB8_SNORM, GL_RGBA8_SNORM}) { + const auto applicable = GetApplicablePixelFormatNormalizeOptions(internalFormat, requested); + EXPECT_TRUE(applicable & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + // The two bits are per precision class, so the 16-bit one never reaches an 8-bit format - + // that is what keeps the fallback reason from naming both. + EXPECT_FALSE(applicable & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + } + for (const GLenum internalFormat : {GL_R16_SNORM, GL_RG16_SNORM, GL_RGB16_SNORM, GL_RGBA16_SNORM}) { + const auto applicable = GetApplicablePixelFormatNormalizeOptions(internalFormat, requested); + EXPECT_TRUE(applicable & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + EXPECT_FALSE(applicable & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget) + << "internalformat 0x" << std::hex << internalFormat; + } + // GL_RGB16_SNORM used to be granted the 16-bit bit only when the three-channel widening was + // requested alongside it, which made the answer depend on the order the caller assembled its + // option set in. The capability probe and the runtime storage choice assemble different sets. + EXPECT_TRUE(GetApplicablePixelFormatNormalizeOptions(GL_RGB16_SNORM, + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) & + PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget); + + // Nothing else responds to either bit; an unsigned-normalized or float format keeps its storage. + for (const GLenum internalFormat : {GL_R8, GL_R16, GL_RGBA8, GL_RGBA16, GL_RGB16F, GL_RGBA32F, GL_RGB9_E5}) { + EXPECT_FALSE(GetApplicablePixelFormatNormalizeOptions(internalFormat, requested)) + << "internalformat 0x" << std::hex << internalFormat; + } +} + +TEST_F(TextureTest, SnormRenderTargetSubstitutesKeepEveryChannelValueExactly) { + using MG_Util::TextureFormatProcessor::NormalizePixelFormat; + struct Case { + GLenum requested; + Flags options; + GLenum internalFormat; + GLenum format; + GLenum type; + }; + const Flags snorm8RT = PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget; + const Flags snorm16RT = PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; + + const Case cases[] = { + // 8-bit: a half float represents every v/127 exactly (the worst case, -123/127, quantizes + // 0.03 of a SNORM step away), so it is the same storage GL_RGBA8_SNORM already always got. + {GL_R8_SNORM, snorm8RT, GL_R16F, GL_RED, GL_FLOAT}, + {GL_RG8_SNORM, snorm8RT, GL_RG16F, GL_RG, GL_FLOAT}, + {GL_RGBA8_SNORM, snorm8RT, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + // 16-bit: NOT a half float. Its spacing just below 1.0 is some 16 SNORM steps, so it hands + // -23451/32767 back as -23457 against a conformance window of one step; a 32-bit float + // round-trips all 65535 channel values. + {GL_R16_SNORM, snorm16RT, GL_R32F, GL_RED, GL_FLOAT}, + {GL_RG16_SNORM, snorm16RT, GL_RG32F, GL_RG, GL_FLOAT}, + {GL_RGBA16_SNORM, snorm16RT, GL_RGBA32F, GL_RGBA, GL_FLOAT}, + // The render-target bit outranks the narrower fallbacks, whichever way the caller's option + // set was assembled: the capability probe folds the driver options in, the runtime storage + // choice can see the render-target bit alone, and the two have to pick the same storage. + {GL_R16_SNORM, snorm16RT | PixelFormatNormalizeOptionBit::NoNorm16, GL_R32F, GL_RED, GL_FLOAT}, + {GL_RG16_SNORM, snorm16RT | PixelFormatNormalizeOptionBit::NoSnorm16, GL_RG32F, GL_RG, GL_FLOAT}, + {GL_RGBA16_SNORM, + snorm16RT | PixelFormatNormalizeOptionBit::NoNorm16 | PixelFormatNormalizeOptionBit::NoSnorm16, + GL_RGBA32F, GL_RGBA, GL_FLOAT}, + // The three-channel formats go on through the widening, which outranks everything. + {GL_RGB8_SNORM, snorm8RT | PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget, GL_RGBA16F, GL_RGBA, + GL_FLOAT}, + {GL_RGB16_SNORM, snorm16RT | PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget, GL_RGBA32F, GL_RGBA, + GL_FLOAT}, + // Control: with EXT_render_snorm neither bit is ever set, so the driver that renders to the + // signed-normalized encoding keeps storing it byte for byte. This is the shape Adreno and + // llvmpipe take, which is why the substitution is invisible on every gate the project runs. + {GL_R8_SNORM, PixelFormatNormalizeOptionBit::None, GL_R8_SNORM, GL_RED, GL_BYTE}, + {GL_RG8_SNORM, PixelFormatNormalizeOptionBit::None, GL_RG8_SNORM, GL_RG, GL_BYTE}, + {GL_R16_SNORM, PixelFormatNormalizeOptionBit::None, GL_R16_SNORM, GL_RED, GL_SHORT}, + {GL_RG16_SNORM, PixelFormatNormalizeOptionBit::None, GL_RG16_SNORM, GL_RG, GL_SHORT}, + {GL_RGBA16_SNORM, PixelFormatNormalizeOptionBit::None, GL_RGBA16_SNORM, GL_RGBA, GL_SHORT}, + // ...and the bit for the other precision class does nothing on its own. + {GL_R8_SNORM, snorm16RT, GL_R8_SNORM, GL_RED, GL_BYTE}, + {GL_R16_SNORM, snorm8RT, GL_R16_SNORM, GL_RED, GL_SHORT}, + }; + + for (const auto& testCase : cases) { + GLenum internalFormat = 0; + GLenum format = 0; + GLenum type = 0; + NormalizePixelFormat(testCase.requested, testCase.options, &internalFormat, &format, &type); + EXPECT_EQ(internalFormat, testCase.internalFormat) << "requested 0x" << std::hex << testCase.requested; + EXPECT_EQ(format, testCase.format) << "requested 0x" << std::hex << testCase.requested; + EXPECT_EQ(type, testCase.type) << "requested 0x" << std::hex << testCase.requested; + } } TEST_F(TextureTest, ThreeChannelRenderTargetOptionAppliesToEveryDeniedThreeChannelFormat) { @@ -3918,9 +4043,10 @@ TEST_F(TextureTest, ThreeChannelWideningRetargetsInternalFormatAndTransferPairTo {GL_RGB16F, widen, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT}, {GL_RGB32F, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, // 16-bit SNORM keeps its encoding where EXT_render_snorm can render to it; a half float's - // 11-bit mantissa cannot represent a 16-bit SNORM channel exactly. + // 11-bit mantissa cannot represent a 16-bit SNORM channel exactly, so the driver that + // cannot render to the encoding gets the 32-bit float rather than the half. {GL_RGB16_SNORM, widen, GL_RGBA16_SNORM, GL_RGBA, GL_SHORT}, - {GL_RGB16_SNORM, widenNoSnorm16, GL_RGBA16F, GL_RGBA, GL_FLOAT}, + {GL_RGB16_SNORM, widenNoSnorm16, GL_RGBA32F, GL_RGBA, GL_FLOAT}, // 16-bit UNORM and the legacy 10/12-bit formats stored as RGB16. {GL_RGB16, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, {GL_RGB10, widen, GL_RGBA32F, GL_RGBA, GL_FLOAT}, diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 460060e1..ab24e9d9 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -558,8 +558,9 @@ namespace MobileGL::MG_Util::SelfTest { } 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"); + "render target is stored as a float (8-bit -> *16F, 16-bit -> *32F, which " + "is the narrowest float that still holds a 16-bit SNORM channel exactly) " + "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 diff --git a/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp b/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp index 6829dbad..7ae3886d 100644 --- a/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp +++ b/MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp @@ -31,32 +31,41 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { applicableOptions |= options & PixelFormatNormalizeOptionBit::NoRgb16; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; break; + // The two render-target bits reach EVERY signed-normalized format, one-, two- and + // four-channel included. They used to be granted to GL_RGB16_SNORM alone, which left the + // other seven with no colour-renderable fallback at all on a driver without + // EXT_render_snorm: an R8_SNORM or R16_SNORM attachment (what KHR-GL4x.texture_swizzle + // renders into for every SNORM source format) got no substitute, so the ES framebuffer was + // incomplete, the draw landed nowhere and the readback fell through to the never-written + // CPU shadow. case GL_RGB16_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoRGB16Snorm; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoNorm16; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm16; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; - if (options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget) { - applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; - } + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; break; case GL_RGBA16_SNORM: case GL_RG16_SNORM: case GL_R16_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoNorm16; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm16; + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget; break; case GL_RGBA8_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoRGBA8Snorm; + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget; break; case GL_RGB8_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8; applicableOptions |= options & PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget; + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget; break; case GL_RG8_SNORM: case GL_R8_SNORM: applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8; + applicableOptions |= options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget; 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 @@ -113,9 +122,12 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { 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. + // signed-normalized encoding whenever the driver can render to it - and when it + // cannot, widen to the 32-bit float, which is the only renderable storage that + // still holds all 65535 channel values exactly. GL_RGBA16F here handed -23451/32767 + // back as -23457, six times the +/-1-step window KHR-GL4x.texture_swizzle allows. return (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) - ? ThreeChannelWidening{GL_RGBA16F, GL_RGBA, GL_FLOAT} + ? ThreeChannelWidening{GL_RGBA32F, 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. @@ -203,7 +215,17 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { } *outInternalFormat = internalFormat; break; + // NoSnorm16RenderTarget outranks the other two 16-bit fallbacks on purpose: it is the + // only one whose substitute has to be EXACT, so it picks the 32-bit float rather than + // the half the driver/ANGLE fallbacks settle for. The capability probe folds the + // driver options and the render-target options into one set while the runtime storage + // choice can see the render-target bit alone (GetRuntimeFallbackNormalizeOptions), so + // the two would disagree on the storage format without a fixed precedence. case GL_RGBA16_SNORM: + if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { + *outInternalFormat = GL_RGBA32F; + break; + } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { *outInternalFormat = GL_RGBA16F; @@ -212,6 +234,12 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_RGB16_SNORM: + // The three-channel widening below replaces this whenever the target has to stay + // renderable; GL_RGB32F keeps the precision for the targets that do not. + if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { + *outInternalFormat = GL_RGB32F; + break; + } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoRGB16Snorm) || (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { @@ -221,6 +249,10 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_RG16_SNORM: + if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { + *outInternalFormat = GL_RG32F; + break; + } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { *outInternalFormat = GL_RG16F; @@ -229,6 +261,10 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { *outInternalFormat = internalFormat; break; case GL_R16_SNORM: + if (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget) { + *outInternalFormat = GL_R32F; + break; + } if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { *outInternalFormat = GL_R16F; @@ -236,30 +272,36 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { } *outInternalFormat = internalFormat; break; + // 8-bit SNORM: the half float already IS exact here, so the render-target bit lands on + // the same storage the other two 8-bit fallbacks pick. case GL_RGBA8_SNORM: if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || - (options & PixelFormatNormalizeOptionBit::NoRGBA8Snorm)) { + (options & PixelFormatNormalizeOptionBit::NoRGBA8Snorm) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outInternalFormat = GL_RGBA16F; break; } *outInternalFormat = internalFormat; break; case GL_RGB8_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { + if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outInternalFormat = GL_RGB16F; break; } *outInternalFormat = internalFormat; break; case GL_RG8_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { + if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outInternalFormat = GL_RG16F; break; } *outInternalFormat = internalFormat; break; case GL_R8_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { + if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outInternalFormat = GL_R16F; break; } @@ -533,7 +575,8 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { if ((options & PixelFormatNormalizeOptionBit::NoNorm16) || (internalFormat == GL_RGB16_SNORM && (options & PixelFormatNormalizeOptionBit::NoRGB16Snorm)) || - (options & PixelFormatNormalizeOptionBit::NoSnorm16)) { + (options & PixelFormatNormalizeOptionBit::NoSnorm16) || + (options & PixelFormatNormalizeOptionBit::NoSnorm16RenderTarget)) { *outType = GL_FLOAT; break; } else { @@ -543,7 +586,8 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { case GL_RGB8_SNORM: case GL_RG8_SNORM: case GL_R8_SNORM: - if (options & PixelFormatNormalizeOptionBit::NoSnorm8) { + if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outType = GL_FLOAT; break; } @@ -551,7 +595,8 @@ namespace MobileGL::MG_Util::TextureFormatProcessor { break; case GL_RGBA8_SNORM: if ((options & PixelFormatNormalizeOptionBit::NoSnorm8) || - (options & PixelFormatNormalizeOptionBit::NoRGBA8Snorm)) { + (options & PixelFormatNormalizeOptionBit::NoRGBA8Snorm) || + (options & PixelFormatNormalizeOptionBit::NoSnorm8RenderTarget)) { *outType = GL_FLOAT; break; } diff --git a/MobileGL/MG_Util/Texture/TextureFormatProcessor.h b/MobileGL/MG_Util/Texture/TextureFormatProcessor.h index f3de4163..23c3d46c 100644 --- a/MobileGL/MG_Util/Texture/TextureFormatProcessor.h +++ b/MobileGL/MG_Util/Texture/TextureFormatProcessor.h @@ -29,11 +29,21 @@ namespace MobileGL { // 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 - // EXT_render_snorm. Without them the only renderable widening left is a half float, whose - // 11-bit mantissa cannot represent a 16-bit SNORM channel exactly. + // A 16-bit signed-normalized image has to back a colour attachment, and the driver cannot + // render to the signed-normalized encoding itself: that needs both EXT_texture_norm16 and + // EXT_render_snorm, and without either one an R16_SNORM / RG16_SNORM / RGB16_SNORM / + // RGBA16_SNORM attachment is texture-only, so the framebuffer is never complete and the + // draw silently lands nowhere. The substitute is a 32-bit float, NOT the half float the + // other SNORM fallbacks use: a half's 11-bit mantissa cannot represent a 16-bit SNORM + // channel exactly - its spacing just below 1.0 is 2^-11, some 16 SNORM steps, so + // -23451/32767 comes back as -23457 - while a 32-bit float round-trips every one of the + // 65535 channel values bit for bit. NoSnorm16RenderTarget = 1 << 8, + // The 8-bit twin of the bit above: without EXT_render_snorm an R8_SNORM / RG8_SNORM / + // RGB8_SNORM / RGBA8_SNORM colour attachment is not renderable either. Here a half float + // IS exact - every value in [-127, 127] divided by 127 round-trips through a half - so the + // substitute matches what the always-on GL_RGBA8_SNORM fallback already picks. + NoSnorm8RenderTarget = 1 << 9, None = 0, }; namespace MG_Util::TextureFormatProcessor {