[Fix, Test] (MG_Backend/DirectGLES, MG_State, MG_Util): publish the real default framebuffer depth/stencil format, forward the clip distance enables, and stop trusting a stale format probe for non-2D depth attachments

This commit is contained in:
2026-08-12 13:34:39 -04:00
parent f2c879528f
commit e7d6bfddac
8 changed files with 981 additions and 36 deletions
+204 -36
View File
@@ -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<GLenum>(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<GLenum>(m_stencilPass[face]));
g_GLESFuncs.glStencilMaskSeparate(faces[face], static_cast<GLuint>(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<GLuint>(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<GLuint>(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<GLuint>(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<GLuint>(previous));
ClearGLErrors();
}
return static_cast<GLenum>(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<GLuint>(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<GLint>(eglDepth);
}
if (g_EGLFuncs.eglGetConfigAttrib(g_Display, g_Config, EGL_STENCIL_SIZE, &eglStencil)) {
stencilBits = static_cast<GLint>(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<int>(depthFormat),
static_cast<int>(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;
@@ -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
@@ -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 <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
glGetProgramInfoLog(program, static_cast<GLsizei>(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<GLenum>(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<char> log(static_cast<size_t>(length > 1 ? length : 1), '\0');
glGetShaderInfoLog(shader, static_cast<GLsizei>(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
@@ -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 <cmath>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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
@@ -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<Uint>(cap) - static_cast<Uint>(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;
}
@@ -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 "<Name>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 {
@@ -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);
@@ -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".