diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 1cbf6658..418ae89c 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1568,6 +1568,28 @@ namespace MobileGL::MG_Backend::DirectGLES { #undef SYNC_CAPABILITY } + if (tailSpanDirty && g_GLESCapabilities.SupportsClipDistance) { + // gl_ClipDistance clipping is per-distance enable state in GL, and ES reaches it + // only through GL_EXT_clip_cull_distance. That extension reuses the desktop enum + // values for CLIP_DISTANCE0_EXT..7_EXT, but the token is absent from the ES + // headers this file compiles against, hence the local name. Without the + // extension there is nowhere to put the state and the shader could not have + // compiled either, so the whole block is gated rather than silently no-op'ing. + constexpr GLenum kClipDistance0 = 0x3000; + constexpr Uint kClipDistanceCount = 8; + const Uint32 mask = parameters.ClipDistanceEnabledMask; + const Uint32 syncedMask = g_syncedRenderStateParameters.ClipDistanceEnabledMask; + if (forceFullPush || mask != syncedMask) { + const Uint32 changed = forceFullPush ? ~0u : (mask ^ syncedMask); + for (Uint i = 0; i < kClipDistanceCount; ++i) { + const Uint32 bit = 1u << i; + if ((changed & bit) == 0) continue; + const GLenum cap = static_cast(kClipDistance0 + i); + (mask & bit) ? g_GLESFuncs.glEnable(cap) : g_GLESFuncs.glDisable(cap); + } + } + } + { // sRGB framebuffer writes. GLES core always encodes a write into an sRGB attachment, // while GL_FRAMEBUFFER_SRGB is disabled by default in desktop GL and the frontend // never turns it on, so the driver has to be told to write raw. Without this a render @@ -3911,8 +3933,11 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glGetIntegerv(GL_STENCIL_BACK_PASS_DEPTH_FAIL, &m_stencilDepthFail[1]); g_GLESFuncs.glGetIntegerv(GL_STENCIL_PASS_DEPTH_PASS, &m_stencilPass[0]); g_GLESFuncs.glGetIntegerv(GL_STENCIL_BACK_PASS_DEPTH_PASS, &m_stencilPass[1]); - for (CapabilityState& capability : m_capabilities) { - capability.enabled = g_GLESFuncs.glIsEnabled(capability.cap); + if (g_GLESCapabilities.SupportsClipDistance) { + m_capabilityCount = kBaseCapabilityCount + kClipDistanceCapabilityCount; + } + for (Uint i = 0; i < m_capabilityCount; ++i) { + m_capabilities[i].enabled = g_GLESFuncs.glIsEnabled(m_capabilities[i].cap); } // GL_SAMPLE_MASK is ES 3.1; on an older driver the query above just raised // GL_INVALID_ENUM and answered GL_FALSE, which is also the right thing to @@ -3934,8 +3959,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // clipped, nothing tested, no coverage games, and colour writes open. Callers // turn back on only what they need (the replicate pass wants the depth and // stencil tests, and masks colour off because it writes neither). - for (const CapabilityState& capability : m_capabilities) { - g_GLESFuncs.glDisable(capability.cap); + for (Uint i = 0; i < m_capabilityCount; ++i) { + g_GLESFuncs.glDisable(m_capabilities[i].cap); } g_GLESFuncs.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); g_GLESFuncs.glDepthMask(GL_FALSE); @@ -3967,11 +3992,11 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(m_stencilPass[face])); g_GLESFuncs.glStencilMaskSeparate(faces[face], static_cast(m_stencilWriteMask[face])); } - for (const CapabilityState& capability : m_capabilities) { - if (capability.enabled) { - g_GLESFuncs.glEnable(capability.cap); + for (Uint i = 0; i < m_capabilityCount; ++i) { + if (m_capabilities[i].enabled) { + g_GLESFuncs.glEnable(m_capabilities[i].cap); } else { - g_GLESFuncs.glDisable(capability.cap); + g_GLESFuncs.glDisable(m_capabilities[i].cap); } } // The per-draw-buffer colour masks are not covered by the non-indexed @@ -4015,12 +4040,24 @@ namespace MobileGL::MG_Backend::DirectGLES { GLint m_stencilPass[2] = {GL_KEEP, GL_KEEP}; Uint m_activeTextureUnit = 0; Bool m_pausedTransformFeedback = false; - CapabilityState m_capabilities[10] = { + // The eight GL_CLIP_DISTANCE0_EXT..7_EXT entries are last so that a driver without + // GL_EXT_clip_cull_distance can be served by shortening the count instead of asking + // it about tokens it does not know. They belong here at all because an emulation pass + // draws its full-screen triangle with its OWN program, which writes no gl_ClipDistance: + // leaving the app's enables on would clip that triangle by undefined distances. + static constexpr Uint kBaseCapabilityCount = 10; + static constexpr Uint kClipDistanceCapabilityCount = 8; + Uint m_capabilityCount = kBaseCapabilityCount; + CapabilityState m_capabilities[kBaseCapabilityCount + kClipDistanceCapabilityCount] = { {GL_SCISSOR_TEST, GL_FALSE}, {GL_DEPTH_TEST, GL_FALSE}, {GL_STENCIL_TEST, GL_FALSE}, {GL_CULL_FACE, GL_FALSE}, {GL_BLEND, GL_FALSE}, {GL_RASTERIZER_DISCARD, GL_FALSE}, {GL_POLYGON_OFFSET_FILL, GL_FALSE}, {GL_SAMPLE_ALPHA_TO_COVERAGE, GL_FALSE}, {GL_SAMPLE_COVERAGE, GL_FALSE}, {GL_SAMPLE_MASK, GL_FALSE}, + {0x3000, GL_FALSE}, {0x3001, GL_FALSE}, + {0x3002, GL_FALSE}, {0x3003, GL_FALSE}, + {0x3004, GL_FALSE}, {0x3005, GL_FALSE}, + {0x3006, GL_FALSE}, {0x3007, GL_FALSE}, }; }; @@ -4038,6 +4075,10 @@ namespace MobileGL::MG_Backend::DirectGLES { // exactly what the replicate rule asks for. Depth comes from gl_FragDepth; stencil has // no shader output on ES, so it is written one bit plane at a time with REPLACE and a // discard for the pixels whose source bit is clear. + // Defined further down with the other small GL helpers; the attachment-format probe below + // needs it to tell a rejected bind apart from a successful one. + static void ClearGLErrors(); + namespace ReplicateBlitImpl { static Uint s_contextGeneration = ~0u; static GLuint s_framebuffer = 0; @@ -4157,6 +4198,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // queryable format at all). The scratch copy has to use the very same one: ES rejects // a depth/stencil blit between differing formats even when both sides are // single-sampled. + // + // The texture branch can only ask about a GL_TEXTURE_2D, and a name whose target is + // something else (an array or cube texture attached by glFramebufferTextureLayer / + // glFramebufferTexture) makes glBindTexture answer GL_INVALID_OPERATION and change + // nothing. Reading glGetTexLevelParameteriv after that failed bind does NOT return 0 - + // it truthfully describes whatever texture was already on GL_TEXTURE_2D, which on this + // path is the emulation's own staging scratch. That is a wrong answer that looks like a + // right one, so the bind has to be error-checked rather than trusted. static GLenum QueryAttachmentSizedFormat(GLenum attachment) { GLint objectType = 0; GLint objectName = 0; @@ -4171,16 +4220,30 @@ namespace MobileGL::MG_Backend::DirectGLES { if (objectType == GL_RENDERBUFFER) { GLint previous = 0; g_GLESFuncs.glGetIntegerv(GL_RENDERBUFFER_BINDING, &previous); + ClearGLErrors(); g_GLESFuncs.glBindRenderbuffer(GL_RENDERBUFFER, static_cast(objectName)); - g_GLESFuncs.glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_INTERNAL_FORMAT, - &internalFormat); + const Bool bound = g_GLESFuncs.glGetError() == GL_NO_ERROR; + if (bound) { + g_GLESFuncs.glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_INTERNAL_FORMAT, + &internalFormat); + } g_GLESFuncs.glBindRenderbuffer(GL_RENDERBUFFER, static_cast(previous)); + ClearGLErrors(); } else if (objectType == GL_TEXTURE) { GLint previous = 0; g_GLESFuncs.glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous); + ClearGLErrors(); g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, static_cast(objectName)); - g_GLESFuncs.glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_INTERNAL_FORMAT, &internalFormat); + const Bool bound = g_GLESFuncs.glGetError() == GL_NO_ERROR; + if (bound) { + g_GLESFuncs.glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_INTERNAL_FORMAT, + &internalFormat); + if (g_GLESFuncs.glGetError() != GL_NO_ERROR) { + internalFormat = 0; + } + } g_GLESFuncs.glBindTexture(GL_TEXTURE_2D, static_cast(previous)); + ClearGLErrors(); } return static_cast(internalFormat); } @@ -6068,30 +6131,15 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glGetFramebufferAttachmentParameteriv(GL_READ_FRAMEBUFFER, point, GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, &objectType); ClearGLErrors(); - if (objectType == GL_NONE) { - return false; - } - if (!isDefault) { - // A real object: ask it directly. That answer is certainly blit-compatible, - // so it is used on its own. The query binds the attachment as GL_TEXTURE_2D, - // which an array or cube attachment refuses - it answers 0, the size-derived - // guesses below take over, and the refusal must not be left on the error - // queue for the caller's next glGetError to pick up as its own. - const GLenum exact = ReplicateBlitImpl::QueryAttachmentSizedFormat(point); - ClearGLErrors(); - if (exact != 0) { - out->Push(exact); - return true; - } - } else if (s_slots[stencilAspect ? 1 : 0].defaultFramebufferFormat != 0) { - out->Push(s_slots[stencilAspect ? 1 : 0].defaultFramebufferFormat); - } - - // Nothing to ask (or the query failed): rebuild plausible sized formats from the - // channel sizes the attachment points report. The OTHER aspect's size matters as - // much as this one's - a depth buffer that also carries stencil has to be staged - // into a packed scratch, because ES only blits depth between identical formats. + // The channel sizes are read before the "is there anything here" decision, because + // they are the more trustworthy witness. Adreno answers GL_NONE for OBJECT_TYPE on + // an attachment made by glFramebufferTexture (a layered cube/array attachment) while + // still reporting its depth and stencil bits correctly, and taking OBJECT_TYPE at + // its word there makes the whole readback report "no such aspect" for a framebuffer + // that plainly has one. The OTHER aspect's size matters as much as this one's - a + // depth buffer that also carries stencil has to be staged into a packed scratch, + // because ES only blits depth between identical formats. GLint depthBits = 0; GLint stencilBits = 0; GLint componentType = GL_UNSIGNED_NORMALIZED; @@ -6107,6 +6155,28 @@ namespace MobileGL::MG_Backend::DirectGLES { GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &componentType); ClearGLErrors(); + const GLint aspectBits = stencilAspect ? stencilBits : depthBits; + if (objectType == GL_NONE && aspectBits <= 0) { + return false; + } + + if (!isDefault) { + // A real object: ask it directly and try that first. It is only a preference, + // not a verdict - the probe binds the attachment as GL_TEXTURE_2D, and a name + // whose target is not GL_TEXTURE_2D can leave it describing the wrong texture + // (see QueryAttachmentSizedFormat). The size-derived guesses below therefore + // stay in the list behind it, so a wrong first answer costs one rejected blit + // instead of the whole readback. The probe's own refusal must not be left on + // the error queue for the caller's next glGetError to pick up as its own. + const GLenum exact = ReplicateBlitImpl::QueryAttachmentSizedFormat(point); + ClearGLErrors(); + if (exact != 0) { + out->Push(exact); + } + } else if (s_slots[stencilAspect ? 1 : 0].defaultFramebufferFormat != 0) { + out->Push(s_slots[stencilAspect ? 1 : 0].defaultFramebufferFormat); + } + const Bool floatDepth = componentType == GL_FLOAT; const Bool packed = depthBits > 0 && stencilBits > 0; if (stencilAspect) { @@ -6222,7 +6292,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Only this aspect: a packed scratch standing in for a separate attachment // has a second half with nothing to copy into it. g_GLESFuncs.glBlitFramebuffer(x, y, x + width, y + height, 0, 0, width, height, aspectBit, GL_NEAREST); - if (g_GLESFuncs.glGetError() != GL_NO_ERROR) { + const GLenum blitErr = g_GLESFuncs.glGetError(); + if (blitErr != GL_NO_ERROR) { continue; } if (isDefault) { @@ -7564,6 +7635,100 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } + // The frontend's default framebuffer is a placeholder FramebufferObject whose attachments + // carry a format and nothing else (MG_Impl/Init.cpp), and it is built before any surface + // exists - so it starts on a guess, GL_DEPTH32F_STENCIL8. Every attachment query about the + // default framebuffer is answered out of that guess, and being wrong is not cosmetic: GL + // blits depth/stencil only between IDENTICAL formats, so a caller that reads + // GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, allocates the buffer it was told about and blits + // gets GL_INVALID_OPERATION and a silently dropped blit - colour bits included, because a + // rejected glBlitFramebuffer transfers nothing at all. DirectVulkan already publishes its + // real format when it creates the swapchain (SwapchainObject::Create); this is the + // DirectGLES half, and here the answer can simply be asked of the ES default framebuffer. + // + // Only the format is published. The placeholder's 512x512 extent is left alone: all three + // attachments share it, and FramebufferObject::CheckCompleteness requires them to agree, so + // resizing depth/stencil without colour would report the default framebuffer incomplete. + static void PublishDefaultFramebufferDepthStencilFormat() { + auto& defaultFBOInfo = MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo; + if (!defaultFBOInfo || !g_GLESFuncs.glGetFramebufferAttachmentParameteriv) return; + + // GL_DEPTH / GL_STENCIL are the default framebuffer's spellings; a user framebuffer + // would need GL_DEPTH_ATTACHMENT / GL_STENCIL_ATTACHMENT and answers GL_INVALID_ENUM + // for these. Nothing else can be bound this early, but bind explicitly so the answer + // describes the default framebuffer even if this is ever called later. + GLint previousDrawFramebuffer = 0; + g_GLESFuncs.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &previousDrawFramebuffer); + if (previousDrawFramebuffer != 0) { + g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + } + + ClearGLErrors(); + GLint depthBits = 0; + GLint stencilBits = 0; + GLint depthComponentType = GL_UNSIGNED_NORMALIZED; + g_GLESFuncs.glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH, + GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, &depthBits); + g_GLESFuncs.glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_STENCIL, + GL_FRAMEBUFFER_ATTACHMENT_STENCIL_SIZE, &stencilBits); + g_GLESFuncs.glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH, + GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, + &depthComponentType); + ClearGLErrors(); + + if (previousDrawFramebuffer != 0) { + g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(previousDrawFramebuffer)); + } + FramebufferImpl::InvalidateFramebufferBindingCache(); + + // A driver that refuses the query leaves both at 0. Fall back to what the EGL config + // was chosen with, which is what the surface actually has. + if (depthBits == 0 && stencilBits == 0 && g_EGLFuncs.eglGetConfigAttrib && g_Display != EGL_NO_DISPLAY && + g_Config != nullptr) { + EGLint eglDepth = 0; + EGLint eglStencil = 0; + if (g_EGLFuncs.eglGetConfigAttrib(g_Display, g_Config, EGL_DEPTH_SIZE, &eglDepth)) { + depthBits = static_cast(eglDepth); + } + if (g_EGLFuncs.eglGetConfigAttrib(g_Display, g_Config, EGL_STENCIL_SIZE, &eglStencil)) { + stencilBits = static_cast(eglStencil); + } + } + + if (depthBits <= 0 && stencilBits <= 0) { + MGLOG_D("DirectGLES: default framebuffer reports no depth or stencil; leaving the " + "placeholder attachment formats untouched"); + return; + } + + const Bool floatDepth = depthComponentType == GL_FLOAT; + TextureInternalFormat depthFormat = TextureInternalFormat::Depth24Stencil8; + TextureInternalFormat stencilFormat = TextureInternalFormat::Depth24Stencil8; + if (depthBits > 0 && stencilBits > 0) { + // Packed: both frontend attachments name the same combined format, as DirectVulkan does. + depthFormat = (floatDepth || depthBits > 24) ? TextureInternalFormat::Depth32FStencil8 + : TextureInternalFormat::Depth24Stencil8; + stencilFormat = depthFormat; + } else if (depthBits > 0) { + depthFormat = floatDepth ? TextureInternalFormat::DepthComponent32F + : (depthBits <= 16) ? TextureInternalFormat::DepthComponent16 + : TextureInternalFormat::DepthComponent24; + stencilFormat = depthFormat; + } else { + depthFormat = TextureInternalFormat::StencilIndex8; + stencilFormat = TextureInternalFormat::StencilIndex8; + } + + auto* depthTexture = defaultFBOInfo->depthAttachment.get(); + auto* stencilTexture = defaultFBOInfo->stencilAttachment.get(); + if (depthTexture) depthTexture->SetInternalFormat(depthFormat); + if (stencilTexture) stencilTexture->SetInternalFormat(stencilFormat); + MGLOG_D("DirectGLES: default framebuffer depth=%d stencil=%d float=%d; published attachment " + "formats depth=%d stencil=%d", + depthBits, stencilBits, floatDepth ? 1 : 0, static_cast(depthFormat), + static_cast(stencilFormat)); + } + #if defined(__linux__) && !defined(__ANDROID__) static void* OpenX11Lib() { void* x11Lib = dlopen("libX11.so.6", RTLD_LOCAL | RTLD_NOW); @@ -7821,6 +7986,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!MakeCurrent()) return false; ApplyRequestedSwapInterval(); + PublishDefaultFramebufferDepthStencilFormat(); MGLOG_D("EGL context created successfully: display=%p, surface=%p, context=%p. window=%p", g_Display, g_Surface, g_Context, window); @@ -7837,6 +8003,8 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!MakeCurrent()) return false; + PublishDefaultFramebufferDepthStencilFormat(); + MGLOG_D("EGL pbuffer context created successfully: display=%p, surface=%p, context=%p. size=%dx%d", g_Display, g_Surface, g_Context, width, height); return true; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index f0fe055b..9da8b4d8 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -61,6 +61,8 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearThenReadPixelsScenario.cpp Scenarios/DepthStencilReadbackScenario.cpp Scenarios/DepthStencilReadbackMatrixScenario.cpp + Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp + Scenarios/ClipDistanceScenario.cpp Scenarios/SsboArrayLengthScenario.cpp Scenarios/DoublePrecisionScenario.cpp Scenarios/UniformInitializerScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp new file mode 100644 index 00000000..790d399e --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp @@ -0,0 +1,358 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.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 - gl_ClipDistance ACTUALLY CLIPS, AND ONLY WHERE IT IS ENABLED. +// +// CapabilityInput::ClipDistance0..7 existed end to end - the GL enum converted to it, the +// string converter named it, glEnable(GL_CLIP_DISTANCE0 + i) raised no error - and then +// RenderState::SetCapability had no case for it and dropped it into `default: break`. Nothing +// was stored, no version was bumped, and neither backend ever heard about it. The shader half +// worked all along (SPIRV-Cross emits gl_ClipDistance with a +// `#extension GL_EXT_clip_cull_distance : require` that Adreno accepts), so the distances were +// computed and then ignored: no clipping ever happened on DirectGLES, which is the whole of +// KHR-GLxx.clip_distance.functional. glIsEnabled lied about it too - it returned GL_FALSE +// immediately after a successful glEnable. +// +// The assertions are behavioural, not query-shaped, because a query-only test passes against a +// backend that stores the bit and never forwards it. Each case draws one full-viewport triangle +// whose clip distance is positive on one side of the viewport and negative on the other, then +// checks BOTH sides: the kept side proves the draw happened at all, and the clipped side is the +// actual claim. The disabled case is the negative control - the identical shader with the +// identical distances and the enable turned off must leave both sides painted, which is what +// says the pixels below are being removed by clipping and not by something else. + +#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 + +#ifndef GL_CLIP_DISTANCE0 +#define GL_CLIP_DISTANCE0 0x3000 +#endif +#ifndef GL_CLIP_DISTANCE1 +#define GL_CLIP_DISTANCE1 0x3001 +#endif + +namespace MGITest { + namespace { + + // One clip distance per half of the viewport: distance 0 is positive on the right half + // (x > 0 in clip space) and distance 1 is positive on the top half. A vertex shader + // producing a full-screen triangle from gl_VertexID, so no buffers are needed. + const char* const kVertexSource = R"(#version 400 core +out float gl_ClipDistance[2]; +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + vec2 p = positions[gl_VertexID]; + gl_Position = vec4(p, 0.0, 1.0); + gl_ClipDistance[0] = p.x; + gl_ClipDistance[1] = p.y; +} +)"; + + const char* const kFragmentSource = R"(#version 400 core +out vec4 fragColor; +void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + class ClipDistanceScenario : public ScenarioTest { + protected: + GLuint BuildProgram() { + const GLuint vs = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vs, 1, &kVertexSource, nullptr); + glCompileShader(vs); + GLint compiled = 0; + glGetShaderiv(vs, GL_COMPILE_STATUS, &compiled); + if (!compiled) { + m_buildLog = ShaderLog(vs); + glDeleteShader(vs); + return 0; + } + const GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fs, 1, &kFragmentSource, nullptr); + glCompileShader(fs); + glGetShaderiv(fs, GL_COMPILE_STATUS, &compiled); + if (!compiled) { + m_buildLog = ShaderLog(fs); + glDeleteShader(vs); + glDeleteShader(fs); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, fs); + glLinkProgram(program); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + glDeleteShader(vs); + glDeleteShader(fs); + if (!linked) { + GLint length = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + std::vector log(static_cast(length > 1 ? length : 1), '\0'); + glGetProgramInfoLog(program, static_cast(log.size()), nullptr, log.data()); + m_buildLog = log.data(); + glDeleteProgram(program); + return 0; + } + return program; + } + + const std::string& BuildLog() const { return m_buildLog; } + + // Paints the whole viewport red, then draws the clipped triangle in green. + void DrawClippedTriangle(GLuint program, GLuint vao) const { + glClearColor(1.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glUseProgram(program); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + } + + static bool IsGreen(const unsigned char* px) { + return px[0] < 64 && px[1] > 192; + } + + static bool IsRed(const unsigned char* px) { + return px[0] > 192 && px[1] < 64; + } + + unsigned char PixelAt(int x, int y, unsigned char* out) const { + glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out); + return out[0]; + } + + // True when the driver under this backend actually implements PER-DISTANCE enable + // state, i.e. when a written-but-disabled gl_ClipDistance leaves its fragments + // alone. Not every stack does, and the difference is not MobileGL's to hide: + // + // - Adreno's ES driver honours GL_CLIP_DISTANCE0_EXT..7_EXT, which is what makes + // KHR-GLxx.clip_distance.functional pass on the device once the enables are + // forwarded at all. + // - Vulkan has no such state: every clip distance a shader declares is active, + // always. DirectVulkan therefore clips by a disabled distance. + // - Mesa's llvmpipe ES driver behaves like Vulkan here. + // + // Emulating GL's semantics on those two would mean forcing the disabled slots to a + // non-negative value inside the shader, which makes the enable mask part of the + // pipeline key - a feature, not a fix, and deliberately not attempted here. The + // cases that need the real semantics gate on this probe and say so when they skip, + // rather than being deleted or silently weakened. + bool DriverHonoursPerDistanceEnables(GLuint program, GLuint vao) const { + for (int i = 0; i < 8; ++i) { + glDisable(static_cast(GL_CLIP_DISTANCE0 + i)); + } + DrawClippedTriangle(program, vao); + unsigned char negativeSide[4] = {0, 0, 0, 0}; + glReadPixels(Gl().Width() / 4, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, negativeSide); + return IsGreen(negativeSide); + } + + private: + static std::string ShaderLog(GLuint shader) { + GLint length = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length); + std::vector log(static_cast(length > 1 ? length : 1), '\0'); + glGetShaderInfoLog(shader, static_cast(log.size()), nullptr, log.data()); + return log.data(); + } + + std::string m_buildLog; + }; + + } // namespace + + // The state itself: glEnable must be observable through glIsEnabled. This is the cheap half + // of the bug - SetCapability's missing case made the query answer GL_FALSE for a capability + // that had just been enabled without error. + TEST_F(ClipDistanceScenario, EnableIsObservableThroughIsEnabled) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_FALSE) << "GL_CLIP_DISTANCE0 must start disabled"; + glEnable(GL_CLIP_DISTANCE0); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_TRUE) + << "glEnable(GL_CLIP_DISTANCE0) raised no error but glIsEnabled still reports it disabled"; + EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE1), GL_FALSE) + << "enabling distance 0 must not enable distance 1 - the eight are independent"; + + glEnable(GL_CLIP_DISTANCE1); + glDisable(GL_CLIP_DISTANCE0); + EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE0), GL_FALSE); + EXPECT_EQ(glIsEnabled(GL_CLIP_DISTANCE1), GL_TRUE); + + glDisable(GL_CLIP_DISTANCE1); + EXPECT_EQ(FirstGLError(), 0u); + gl.EndFrame(); + } + + // The claim: an enabled clip distance removes the fragments where it is negative. + TEST_F(ClipDistanceScenario, AnEnabledClipDistanceRemovesTheNegativeHalf) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + const GLuint program = BuildProgram(); + ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + + glEnable(GL_CLIP_DISTANCE0); + DrawClippedTriangle(program, vao); + EXPECT_EQ(FirstGLError(), 0u); + + unsigned char right[4] = {0, 0, 0, 0}; + unsigned char left[4] = {0, 0, 0, 0}; + PixelAt(width - 1 - width / 4, height / 2, right); + PixelAt(width / 4, height / 2, left); + EXPECT_EQ(FirstGLError(), 0u); + + EXPECT_TRUE(IsGreen(right)) << "the kept half is not painted (" << int(right[0]) << "," << int(right[1]) + << "," << int(right[2]) << ") - the draw itself did not happen, so the clipped " + "half below proves nothing"; + EXPECT_TRUE(IsRed(left)) << "gl_ClipDistance[0] is negative on the left half and GL_CLIP_DISTANCE0 is " + "enabled, so those fragments must be clipped away; found (" + << int(left[0]) << "," << int(left[1]) << "," << int(left[2]) << ")"; + + glDisable(GL_CLIP_DISTANCE0); + glUseProgram(0); + glBindVertexArray(0); + glDeleteProgram(program); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + + // The negative control: the same shader writing the same distances, with the enable off, + // must paint both halves. Without this a backend that clipped everything - or one whose + // draw simply failed - would pass the case above. + TEST_F(ClipDistanceScenario, ADisabledClipDistanceRemovesNothing) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + const GLuint program = BuildProgram(); + ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_CLIP_DISTANCE0); + glDisable(GL_CLIP_DISTANCE1); + + DrawClippedTriangle(program, vao); + EXPECT_EQ(FirstGLError(), 0u); + + unsigned char right[4] = {0, 0, 0, 0}; + unsigned char left[4] = {0, 0, 0, 0}; + PixelAt(width - 1 - width / 4, height / 2, right); + PixelAt(width / 4, height / 2, left); + EXPECT_EQ(FirstGLError(), 0u); + + EXPECT_TRUE(IsGreen(right)) << "with every clip distance disabled the whole triangle must survive"; + if (!IsGreen(left)) { + GTEST_SKIP() << "renderer " << gl.RendererString() + << " clips by a DISABLED gl_ClipDistance - it does not implement per-distance enable state " + "(see DriverHonoursPerDistanceEnables). Emulating GL's semantics there needs shader-side " + "masking keyed on the enable mask, which is a separate feature"; + } + + glUseProgram(0); + glBindVertexArray(0); + glDeleteProgram(program); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + + // The eight enables are independent: enabling only distance 1 must clip by distance 1 and + // leave distance 0 alone. A backend that forwarded "any clip distance enabled" as a single + // bit, or that always enables every declared distance (which is what Vulkan does natively), + // passes both cases above and fails this one. + TEST_F(ClipDistanceScenario, TheEnablesAreIndependentPerDistance) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + ASSERT_GE(width, 8); + ASSERT_GE(height, 8); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + const GLuint program = BuildProgram(); + ASSERT_NE(program, 0u) << "the gl_ClipDistance program did not build: " << BuildLog(); + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + if (!DriverHonoursPerDistanceEnables(program, vao)) { + glUseProgram(0); + glBindVertexArray(0); + glDeleteProgram(program); + glDeleteVertexArrays(1, &vao); + GTEST_SKIP() << "renderer " << gl.RendererString() + << " clips by every declared gl_ClipDistance regardless of the enables, so per-distance " + "independence is not observable here"; + } + + glDisable(GL_CLIP_DISTANCE0); + glEnable(GL_CLIP_DISTANCE1); + + DrawClippedTriangle(program, vao); + EXPECT_EQ(FirstGLError(), 0u); + + // Distance 1 is negative on the bottom half, distance 0 on the left half. With only + // distance 1 enabled, the bottom-left must survive (distance 0 is off) and the bottom + // must not. + unsigned char topLeft[4] = {0, 0, 0, 0}; + unsigned char bottomRight[4] = {0, 0, 0, 0}; + PixelAt(width / 4, height - 1 - height / 4, topLeft); + PixelAt(width - 1 - width / 4, height / 4, bottomRight); + EXPECT_EQ(FirstGLError(), 0u); + + EXPECT_TRUE(IsGreen(topLeft)) << "gl_ClipDistance[0] is negative here but GL_CLIP_DISTANCE0 is disabled, so " + "this fragment must survive"; + EXPECT_TRUE(IsRed(bottomRight)) << "gl_ClipDistance[1] is negative here and GL_CLIP_DISTANCE1 is enabled, so " + "this fragment must be clipped"; + + glDisable(GL_CLIP_DISTANCE1); + glUseProgram(0); + glBindVertexArray(0); + glDeleteProgram(program); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp new file mode 100644 index 00000000..9d170590 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackAttachmentShapeScenario.cpp @@ -0,0 +1,362 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackAttachmentShapeScenario.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 - DEPTH/STENCIL READBACK WHEN THE ATTACHMENT IS NOT A PLAIN GL_TEXTURE_2D, +// AND THE DEFAULT FRAMEBUFFER'S ADVERTISED DEPTH/STENCIL FORMAT. +// +// Three shipped defects, all of them invisible to a test that only ever attaches a 2D texture +// or only ever asks the default framebuffer for a colour value. +// +// (1) The ES depth/stencil readback emulation identifies the source format by binding the +// attachment's texture NAME to GL_TEXTURE_2D and asking that target for its internal +// format. A name whose target is GL_TEXTURE_2D_ARRAY (attached by +// glFramebufferTextureLayer) makes the bind answer GL_INVALID_OPERATION and change +// nothing - so the query then truthfully describes whatever texture was already on +// GL_TEXTURE_2D, which on that path is the emulation's own staging scratch. A wrong +// answer that looks like a right one: the staging blit is issued between mismatched +// depth formats, ES rejects it, and the read reports nothing at all. +// +// (2) Adreno answers GL_NONE for GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE on an attachment made +// by glFramebufferTexture (a cube map, attached layered) while still reporting its depth +// and stencil bits correctly. The emulation took OBJECT_TYPE as the sole witness for "is +// there an aspect here at all" and declined the whole read. +// +// (3) DirectGLES never told the frontend what its default framebuffer's depth/stencil format +// actually is, so the placeholder from MG_Impl/Init.cpp - GL_DEPTH32F_STENCIL8 - was what +// every attachment query answered, whatever the surface really had. That is not cosmetic: +// GL blits depth/stencil only between IDENTICAL formats, so an application that reads +// GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, allocates the buffer it was just told about and +// blits gets GL_INVALID_OPERATION - and a rejected glBlitFramebuffer transfers NOTHING, +// colour bits included. DirectVulkan has published its real format since the swapchain +// work; this is the half that was missing. +// +// Every case poisons its destination with a value the correct answer cannot be, so "the +// backend wrote nothing" fails loudly instead of passing on stale memory. The plain +// GL_TEXTURE_2D case at the end is the built-in control: it shares every line of the readback +// path with the array and cube cases, so its passing is what says a failure above is about the +// attachment's SHAPE and not about depth readback in general. +// +// The scenario name starts with DepthStencilReadback on purpose - that is the filter the +// forced-emulation ctest registration uses (MG_IntegrationTest/CMakeLists.txt), and without +// that registration these cases are unfalsifiable on llvmpipe, which accepts the native ES +// depth reads that the Adreno device does not have. + +#include +#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 float kDepthPoison = 0.2f; + constexpr int kStencilPoison = 50; + constexpr float kDepthValue = 0.75f; + constexpr int kStencilValue = 7; + constexpr int kSize = 16; + + class DepthStencilReadbackAttachmentShapeScenario : public ScenarioTest { + protected: + float ReadDepthAt(int x, int y) const { + float depth = kDepthPoison; + glReadPixels(x, y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth); + return depth; + } + + int ReadStencilAt(int x, int y) const { + int stencil = kStencilPoison; + glReadPixels(x, y, 1, 1, GL_STENCIL_INDEX, GL_INT, &stencil); + return stencil; + } + + // Clears the currently bound framebuffer's depth and stencil to the shared + // reference values, with both write masks explicitly open (glClear honours them, + // and a leftover mask from another scenario in this shared context would look + // exactly like the bug under test). + void ClearDepthStencil() const { + glDepthMask(GL_TRUE); + glStencilMask(0xFFu); + glDisable(GL_SCISSOR_TEST); + glClearDepth(kDepthValue); + glClearStencil(kStencilValue); + glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + } + }; + + // Fails the calling test if the framebuffer bound at both targets is not complete; + // an incomplete framebuffer would make every read below return the poison for a + // reason that has nothing to do with what is being tested. + ::testing::AssertionResult FramebufferIsComplete() { + const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status == GL_FRAMEBUFFER_COMPLETE) return ::testing::AssertionSuccess(); + return ::testing::AssertionFailure() << "framebuffer status 0x" << std::hex << status; + } + + } // namespace + + // (1) A depth slice of a 2D ARRAY texture, attached with glFramebufferTextureLayer. + // Pre-fix this read back the poison: the format probe answered with the staging scratch's + // GL_DEPTH24_STENCIL8 instead of the array's GL_DEPTH_COMPONENT24, and the mismatched + // staging blit was rejected. + TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfAnArrayLayerAttachmentReadsBack) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLuint fbo = 0; + GLuint depthArray = 0; + glGenFramebuffers(1, &fbo); + glGenTextures(1, &depthArray); + glBindTexture(GL_TEXTURE_2D_ARRAY, depthArray); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH_COMPONENT24, kSize, kSize, 4); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + // Layer 2, not layer 0: a backend that silently reads the wrong slice would still + // agree with a single-layer texture. + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthArray, 0, 2); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + EXPECT_EQ(FirstGLError(), 0u); + ASSERT_TRUE(FramebufferIsComplete()); + + glViewport(0, 0, kSize, kSize); + ClearDepthStencil(); + + const float depth = ReadDepthAt(kSize / 2, kSize / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f) + << "glReadPixels(GL_DEPTH_COMPONENT) of a GL_TEXTURE_2D_ARRAY layer attachment returned " << depth + << (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : ""); + + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &depthArray); + gl.EndFrame(); + } + + // (2) A depth cube map, attached whole with glFramebufferTexture - a LAYERED attachment. + // Pre-fix the emulation declined outright, because the driver reports GL_NONE for that + // attachment's OBJECT_TYPE. + TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfALayeredCubeAttachmentReadsBack) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLuint fbo = 0; + GLuint depthCube = 0; + glGenFramebuffers(1, &fbo); + glGenTextures(1, &depthCube); + glBindTexture(GL_TEXTURE_CUBE_MAP, depthCube); + glTexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_DEPTH_COMPONENT24, kSize, kSize); + glBindTexture(GL_TEXTURE_CUBE_MAP, 0); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCube, 0); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + EXPECT_EQ(FirstGLError(), 0u); + ASSERT_TRUE(FramebufferIsComplete()); + + glViewport(0, 0, kSize, kSize); + ClearDepthStencil(); + + const float depth = ReadDepthAt(kSize / 2, kSize / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f) + << "glReadPixels(GL_DEPTH_COMPONENT) of a layered GL_TEXTURE_CUBE_MAP attachment returned " << depth + << (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : ""); + + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &depthCube); + gl.EndFrame(); + } + + // Both aspects of a packed array attachment. The stencil half goes through a different + // sampling mode than the depth half, and only the depth half was covered above. + TEST_F(DepthStencilReadbackAttachmentShapeScenario, PackedArrayLayerAttachmentReadsBackBothAspects) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLuint fbo = 0; + GLuint packedArray = 0; + glGenFramebuffers(1, &fbo); + glGenTextures(1, &packedArray); + glBindTexture(GL_TEXTURE_2D_ARRAY, packedArray); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_DEPTH24_STENCIL8, kSize, kSize, 3); + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, packedArray, 0, 1); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + EXPECT_EQ(FirstGLError(), 0u); + ASSERT_TRUE(FramebufferIsComplete()); + + glViewport(0, 0, kSize, kSize); + ClearDepthStencil(); + + const float depth = ReadDepthAt(kSize / 2, kSize / 2); + const int stencil = ReadStencilAt(kSize / 2, kSize / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f) + << "depth of a packed GL_TEXTURE_2D_ARRAY layer attachment returned " << depth; + EXPECT_EQ(stencil, kStencilValue) + << "stencil of a packed GL_TEXTURE_2D_ARRAY layer attachment returned " << stencil + << (stencil == kStencilPoison ? " - the destination was never written at all" : ""); + + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &packedArray); + gl.EndFrame(); + } + + // The control: the plain GL_TEXTURE_2D shape, which always worked. If this one ever fails + // alongside the three above, the fault is in depth readback generally rather than in how + // the attachment's format and presence are discovered. + TEST_F(DepthStencilReadbackAttachmentShapeScenario, DepthOfAPlainTexture2DAttachmentReadsBack) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + + GLuint fbo = 0; + GLuint depthTex = 0; + glGenFramebuffers(1, &fbo); + glGenTextures(1, &depthTex); + glBindTexture(GL_TEXTURE_2D, depthTex); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT24, kSize, kSize); + glBindTexture(GL_TEXTURE_2D, 0); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + EXPECT_EQ(FirstGLError(), 0u); + ASSERT_TRUE(FramebufferIsComplete()); + + glViewport(0, 0, kSize, kSize); + ClearDepthStencil(); + + const float depth = ReadDepthAt(kSize / 2, kSize / 2); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f) + << "the control case failed: even a plain GL_TEXTURE_2D depth attachment read back " << depth; + + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + glDeleteTextures(1, &depthTex); + gl.EndFrame(); + } + + // (3) The default framebuffer must describe its depth/stencil truthfully enough that a + // buffer allocated from that description is blit-compatible with it. This is the exact + // sequence KHR-GLxx.framebuffer_blit performs, and the exact reason 22 of its cases died + // on DirectGLES: the frontend answered 32-bit float depth for a 24-bit fixed-point + // surface, so the renderbuffer the caller allocated could never be blitted to. + TEST_F(DepthStencilReadbackAttachmentShapeScenario, DefaultFramebufferDepthStencilFormatIsBlitCompatible) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + BindDefaultFramebuffer(); + GLint depthBits = 0; + GLint stencilBits = 0; + GLint componentType = GL_UNSIGNED_NORMALIZED; + glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH, + GL_FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE, &depthBits); + glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_STENCIL, + GL_FRAMEBUFFER_ATTACHMENT_STENCIL_SIZE, &stencilBits); + glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_DEPTH, + GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &componentType); + EXPECT_EQ(FirstGLError(), 0u); + if (depthBits <= 0 || stencilBits <= 0) { + GTEST_SKIP() << "this surface has no packed depth/stencil (depth=" << depthBits + << " stencil=" << stencilBits << "); the blit-compatibility contract needs both"; + } + + // The one sized format the reported description names. Getting here with the wrong + // answer is the bug: the two candidates are not interchangeable for a blit. + const GLenum reported = (componentType == GL_FLOAT || depthBits > 24) ? GL_DEPTH32F_STENCIL8 + : GL_DEPTH24_STENCIL8; + + GLuint fbo = 0; + GLuint colorRbo = 0; + GLuint depthRbo = 0; + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &colorRbo); + glGenRenderbuffers(1, &depthRbo); + glBindRenderbuffer(GL_RENDERBUFFER, colorRbo); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height); + glBindRenderbuffer(GL_RENDERBUFFER, depthRbo); + glRenderbufferStorage(GL_RENDERBUFFER, reported, width, height); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorRbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depthRbo); + EXPECT_EQ(FirstGLError(), 0u); + ASSERT_TRUE(FramebufferIsComplete()); + + // Put a known depth in the default framebuffer, then blit colour+depth+stencil out of + // it into the buffer that its own description asked for. + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glClearColor(0.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ClearDepthStencil(); + EXPECT_EQ(FirstGLError(), 0u); + + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo); + glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, + GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); + EXPECT_EQ(FirstGLError(), 0u) + << "blitting depth/stencil out of the default framebuffer into a buffer allocated from the format " + "the default framebuffer itself reported was rejected - the report and the storage disagree"; + + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo); + unsigned char color[4] = {0, 0, 0, 0}; + glReadPixels(width / 2, height / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, color); + const float depth = ReadDepthAt(width / 2, height / 2); + const int stencil = ReadStencilAt(width / 2, height / 2); + EXPECT_EQ(FirstGLError(), 0u); + // The colour bit is the precondition, not the claim: it says this stack can blit out of + // its default framebuffer at all, which has nothing to do with depth/stencil formats. + // DirectVulkan on a surfaceless pbuffer cannot - the whole call, colour included, is a + // no-op there, while the same blit works on a real surface (KHR-GLxx.framebuffer_blit + // exercises exactly it and Magma passes 33/33 on device). Skipping keeps the + // depth/stencil claim below falsifiable instead of drowning it in an unrelated + // harness limitation. + if (int(color[1]) <= 192) { + GTEST_SKIP() << "backend " << gl.BackendName() << " on this surface transferred no colour either (green=" + << int(color[1]) + << "): it cannot blit out of the default framebuffer here, so the depth/stencil half proves " + "nothing. The GL-error assertion above still ran, and it is the format contract"; + } + EXPECT_NEAR(depth, kDepthValue, 1.0f / 4096.0f) + << "depth blitted out of the default framebuffer read back " << depth + << (std::fabs(depth - kDepthPoison) < 1e-6f ? " - the blit transferred nothing" : ""); + EXPECT_EQ(stencil, kStencilValue) << "stencil blitted out of the default framebuffer read back " << stencil; + + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &colorRbo); + glDeleteRenderbuffers(1, &depthRbo); + gl.EndFrame(); + } + +} // namespace MGITest diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index c0feabf8..22c8292b 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -187,6 +187,14 @@ namespace MobileGL { } // -------------------- Capabilities -------------------- + namespace { + // CapabilityInput lists ClipDistance0..7 contiguously (RenderState.h); the caller + // has already rejected anything outside that run, so the subtraction is in range. + Uint32 ClipDistanceBit(CapabilityInput cap) { + return 1u << (static_cast(cap) - static_cast(CapabilityInput::ClipDistance0)); + } + } // namespace + void RenderState::SetCapability(CapabilityInput cap, Bool enabled) { #define SET_CAPABILITY(capability, flag) \ case CapabilityInput::capability: \ @@ -228,6 +236,27 @@ namespace MobileGL { if (stateChanged) BumpVersions(); break; } + case CapabilityInput::ClipDistance0: + case CapabilityInput::ClipDistance1: + case CapabilityInput::ClipDistance2: + case CapabilityInput::ClipDistance3: + case CapabilityInput::ClipDistance4: + case CapabilityInput::ClipDistance5: + case CapabilityInput::ClipDistance6: + case CapabilityInput::ClipDistance7: { + const Uint32 bit = ClipDistanceBit(cap); + const Uint32 updated = + enabled ? (m_parameters.ClipDistanceEnabledMask | bit) + : (m_parameters.ClipDistanceEnabledMask & ~bit); + if (updated == m_parameters.ClipDistanceEnabledMask) break; + m_parameters.ClipDistanceEnabledMask = updated; + // Deliberately NOT BumpVersions(): no backend bakes a clip-distance enable + // into a pipeline object (DirectGLES issues glEnable, DirectVulkan takes the + // set from the shader's declared array), so bumping the pipeline version here + // would evict cached pipelines for state they do not contain. + ++m_version; + break; + } default: // not supported currently break; } @@ -263,6 +292,15 @@ namespace MobileGL { RETURN_CAPABILITY(ProgramPointSize); case CapabilityInput::Blend: return m_parameters.BlendStates[0].Enabled; + case CapabilityInput::ClipDistance0: + case CapabilityInput::ClipDistance1: + case CapabilityInput::ClipDistance2: + case CapabilityInput::ClipDistance3: + case CapabilityInput::ClipDistance4: + case CapabilityInput::ClipDistance5: + case CapabilityInput::ClipDistance6: + case CapabilityInput::ClipDistance7: + return (m_parameters.ClipDistanceEnabledMask & ClipDistanceBit(cap)) != 0; default: return false; } diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.h b/MobileGL/MG_State/GLState/RenderState/RenderState.h index b272fc2e..52009e79 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.h +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.h @@ -303,6 +303,12 @@ namespace MobileGL { Bool StencilTestEnabled = false; Bool ProgramPointSizeEnabled = false; IntVec4 ScissorBox = IntVec4(0, 0, 0, 0); // x, y, width, height + // glEnable(GL_CLIP_DISTANCE0 + i) for i in [0, 8), one bit each. A bitmask rather than + // eight bools because every consumer wants the set, not an individual flag, and because + // the SYNC_CAPABILITY/SET_CAPABILITY macros key off a "Enabled" field name that + // eight numbered capabilities cannot share. Lives in the tail span (after LogicOp), so + // DirectGLES' span memcmp picks a change up like any other capability. + Uint32 ClipDistanceEnabledMask = 0; }; namespace MG_State { diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 94c470c3..81ef0908 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -925,6 +925,9 @@ namespace MobileGL::MG_Util::BackendLoader { if (std::strcmp(extension, "GL_EXT_multi_draw_arrays") == 0) { hasMultiDrawArraysExtension = true; } + if (std::strcmp(extension, "GL_EXT_clip_cull_distance") == 0) { + caps.SupportsClipDistance = true; + } } } // The pointer check on top of the extension check makes each flag sufficient on its own @@ -978,6 +981,7 @@ namespace MobileGL::MG_Util::BackendLoader { MGLOG_I(" compute shaders (ES 3.1 core): %s", caps.SupportsComputeShader ? "yes" : "no"); MGLOG_I(" base instance (EXT_base_instance; emulated by attribute offsets when absent): %s", caps.SupportsBaseInstance ? "yes" : "no"); + MGLOG_I(" clip distances (EXT_clip_cull_distance): %s", caps.SupportsClipDistance ? "yes" : "no"); MGLOG_I("OpenGL ES capabilities:"); glesFuncs.glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &caps.UniformBufferOffsetAlignment); diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index ef362704..1cf6a1b7 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1163,6 +1163,13 @@ namespace MobileGL { // Compute shaders are usable: ES 3.1 core (there is no pre-3.1 extension in ES), with // the dispatch and barrier entry points resolved. Bool SupportsComputeShader = false; + // GL_EXT_clip_cull_distance is present: the driver accepts gl_ClipDistance in ESSL + // (which is what SPIRV-Cross emits, together with a `#extension ... : require`) AND + // the GL_CLIP_DISTANCE0_EXT..7_EXT enable tokens, whose values are the desktop ones. + // ES core has neither at any version, so without this a gl_ClipDistance shader cannot + // compile and the per-distance enables have nowhere to go - clipping silently never + // happens, which is exactly what KHR-GLxx.clip_distance.functional catches. + Bool SupportsClipDistance = false; // GL_RENDERER contains "ANGLE". Bool IsAngleRenderer = false; // GL_RENDERER contains both "ANGLE" and "llvmpipe".