[Feat] (GL): apply glClipControl on both backends and advertise ARB_clip_control

This commit is contained in:
2026-10-12 08:11:18 -04:00
parent 82ef0053ea
commit 6e43ff8ccc
12 changed files with 465 additions and 3 deletions
+3
View File
@@ -320,6 +320,9 @@ namespace MobileGL {
// GL_MAX_CULL_DISTANCES; VkPhysicalDeviceFeatures::shaderCullDistance with a non-zero
// maxCullDistances.
Bool CullDistance = false;
// GL_EXT_clip_control on DirectGLES. DirectVulkan applies clip control in its own
// position fixup and does not read this.
Bool ClipControl = false;
};
// Coarse GPU vendor identity for gating device-specific quirks. Detected from the
@@ -843,7 +843,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
capabilities.SupportsDrawIndirect && capabilities.SupportsBaseInstance,
capabilities.SupportsTextureView, capabilities.SupportsTextureCubeMapArray,
AdvertisedFeatureSupport{.TessellationShader = capabilities.SupportsTessellationShader,
.CullDistance = capabilities.MaxCullDistances > 0});
.CullDistance = capabilities.MaxCullDistances > 0,
.ClipControl = capabilities.SupportsClipControl});
// Exactly what InitCapabilities forwards into the dynamic parameters: the base
// extension and _primitive when the driver has them, _attachment not yet.
auto& extensions = MutableRendererInfo().RendererGLInfo.Extensions;
@@ -1529,6 +1530,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (features.CullDistance) {
extensions.push_back(E_GL_ARB_cull_distance);
}
// glClipControl is forwarded to the driver's glClipControlEXT.
if (features.ClipControl) {
extensions.push_back(E_GL_ARB_clip_control);
}
// Minecraft 26.3 checks this prerequisite before it even considers
// GL_ARB_multi_draw_indirect. ES 3.1 supplies both single-draw entry points; the loader
// folds the version and pointer checks into SupportsDrawIndirect.
@@ -4125,6 +4125,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
if (headSpanDirty) { // Clip control. GL_ARB_clip_control is only advertised where the
// driver has GL_EXT_clip_control, so without it the state can only be the default.
if (g_GLESCapabilities.SupportsClipControl &&
(forceFullPush || parameters.ClipOrigin != g_syncedRenderStateParameters.ClipOrigin ||
parameters.ClipDepthMode != g_syncedRenderStateParameters.ClipDepthMode)) {
g_GLESFuncs.glClipControlEXT(parameters.ClipOrigin, parameters.ClipDepthMode);
}
}
if (headSpanDirty) { // Line width
if (forceFullPush || parameters.LineWidth != g_syncedRenderStateParameters.LineWidth) {
g_GLESFuncs.glLineWidth(parameters.LineWidth);
@@ -7485,6 +7494,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
// - An active transform feedback capture. GL rejects a draw issued with a program
// other than the one that began the capture, and would otherwise append the
// emulation's vertices to the application's buffers.
//
// - The application's clip control. The passes place their triangle in the default
// lower-left, [-1, 1] clip space; an upper-left origin would mirror it vertically.
class ScopedEmulationDrawState {
public:
ScopedEmulationDrawState() {
@@ -7527,6 +7539,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
DrainBlitErrors();
}
if (g_GLESCapabilities.SupportsClipControl) {
g_GLESFuncs.glGetIntegerv(GL_CLIP_ORIGIN, &m_clipOrigin);
g_GLESFuncs.glGetIntegerv(GL_CLIP_DEPTH_MODE, &m_clipDepthMode);
g_GLESFuncs.glClipControlEXT(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
}
m_activeTextureUnit = TextureImpl::g_activeTextureUnit;
TextureImpl::ActivateTextureUnit(TextureImpl::TempTextureUnit);
SamplerImpl::UnbindSampler(TextureImpl::TempTextureUnit);
@@ -7597,6 +7615,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
colorMask.z() ? GL_TRUE : GL_FALSE, colorMask.w() ? GL_TRUE : GL_FALSE);
}
}
if (g_GLESCapabilities.SupportsClipControl) {
g_GLESFuncs.glClipControlEXT(static_cast<GLenum>(m_clipOrigin), static_cast<GLenum>(m_clipDepthMode));
}
if (m_pausedTransformFeedback && g_GLESFuncs.glResumeTransformFeedback) {
g_GLESFuncs.glResumeTransformFeedback();
}
@@ -7628,6 +7649,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLint m_stencilPass[2] = {GL_KEEP, GL_KEEP};
Uint m_activeTextureUnit = 0;
Bool m_pausedTransformFeedback = false;
GLint m_clipOrigin = GL_LOWER_LEFT;
GLint m_clipDepthMode = GL_NEGATIVE_ONE_TO_ONE;
// 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
@@ -916,6 +916,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// - mix() over integer and boolean vectors in GLSL below 4.50. The frontend compiles it
// to OpSelect, which SPIR-V consumes directly.
E_GL_EXT_shader_integer_mix,
// The origin and depth mode select the Y negation and the [-1, 1] -> [0, 1] depth
// remap of the position fixup every program already gets, so no device feature is
// involved (ApplyClipControlToTransformFlags).
E_GL_ARB_clip_control,
// Advertised with GL_NUM_PROGRAM_BINARY_FORMATS = 0, which the
// extension explicitly permits. It is also the only thing that
// exposes glProgramParameteri before GL 4.1.
@@ -75,6 +75,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ZeroBaseVertex = 1 << 8,
};
using CompileOptionFlags = Flags<CompileOptionBit>;
// glClipControl folded into the position fixup's two bits. An upper-left origin negates
// clip-space Y on top of whatever the framebuffer needs (the fixup flips before it rotates,
// so this stays in the logical orientation), and inverts the cull sense with it, which
// keeps facing a property of the clip-space winding as the extension requires. A
// [0, 1] depth mode is Vulkan's own clip volume, so the remap is dropped. gl_FragCoord is
// a window coordinate, which clip control does not move.
static constexpr Uint32 ApplyClipControlToTransformFlags(Uint32 flags, GLenum origin, GLenum depthMode) {
if (origin == GL_UPPER_LEFT) flags ^= static_cast<Uint32>(CompileOptionBit::PositionYFlip);
if (depthMode == GL_ZERO_TO_ONE) flags &= ~static_cast<Uint32>(CompileOptionBit::PositionZRemap);
return flags;
}
using HashType = Uint64;
// The gl_PerVertex members a pass-through tessellation control stage may have to carry,
@@ -520,8 +520,10 @@
// not rewind an arena while this draw owns as-yet-unrecorded slices.
WirePreparationScope preparing(m_wirePreparationDepth);
ProgramFactory::CompileOptionFlags flags =
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(fbo->IsDefault));
const RenderStateParameters& clipControl = MG_Pipe::gPipeInputs->GetRenderStateParameters();
ProgramFactory::CompileOptionFlags flags = ProgramFactory::CompileOptionFlags(
ProgramFactory::ApplyClipControlToTransformFlags(GetBaseTransformFlagsRaw(fbo->IsDefault),
clipControl.ClipOrigin, clipControl.ClipDepthMode));
if (m_transformFeedbackFeatureEnabled && MG_Pipe::gPipeInputs->IsTransformFeedbackActive() &&
program.GetTransformFeedbackVaryingCount() > 0)
flags |= ProgramFactory::CompileOptionBit::XfbCapture;
@@ -94,6 +94,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/FencePollScenario.cpp
Scenarios/PrimitiveRestartScenario.cpp
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClipControlScenario.cpp
Scenarios/ClientVertexArrayScenario.cpp
Scenarios/ClearThenReadPixelsScenario.cpp
# P15 S0: a clear between draws into the same framebuffer (DirectVulkan records it in the pass).
@@ -0,0 +1,331 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClipControlScenario.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 - glClipControl REACHES THE RASTERIZER, AND ONLY THE APPLICATION'S DRAWS.
//
// The state used to be recorded and reported back by glGet but never applied, so an upper-left
// origin drew the right way up and a [0, 1] depth mode kept GL's [-1, 1] clip volume. Each case
// below is a colour-only observable of one half of the extension:
//
// - origin: a primitive in the lower half of clip space lands in the TOP half of the window
// under GL_UPPER_LEFT, on the default framebuffer (which DirectVulkan stores flipped already)
// and on an FBO (which it does not);
// - facing: culling follows the clip-space winding whatever the origin, as the extension
// requires;
// - depth: under GL_ZERO_TO_ONE a primitive at z = -0.5 is outside the clip volume, and one at
// z = 0.25 is written as 0.25 rather than 0.625;
// - a framebuffer blit issued while the application's origin is upper-left is a copy, not a
// draw, and must not come out mirrored.
#include <cstring>
#include <string>
#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 const char* kVS = R"(#version 330 core
in vec3 aPos;
void main() { gl_Position = vec4(aPos, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(1.0, 0.0, 0.0, 1.0); }
)";
// Red where the rasterizer calls the fragment front-facing, green where it calls it back-facing.
constexpr const char* kFacingFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = gl_FrontFacing ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 1.0, 0.0, 1.0); }
)";
// Left half of clip space counter-clockwise, right half clockwise.
constexpr float kWoundHalves[] = {
-1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, -1.0f, 1.0f, 0.0f, //
0.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, //
0.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, -1.0f, 0.0f, //
1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f,
};
bool ExtensionAdvertised(const char* name) {
GLint count = 0;
glGetIntegerv(GL_NUM_EXTENSIONS, &count);
for (GLint i = 0; i < count; ++i) {
const auto* extension = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, i));
if (extension && std::strcmp(extension, name) == 0) return true;
}
return false;
}
// Draws `vertexCount` xyz vertices as GL_TRIANGLES in red.
void DrawTriangles(unsigned int program, const float* xyz, int vertexCount) {
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(vertexCount * 3 * sizeof(float)), xyz,
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glUseProgram(program);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);
glUseProgram(0);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
// The lower half of clip space (y in [-1, 0]) as two triangles at depth z.
void DrawLowerHalf(unsigned int program, float z = 0.0f) {
const float quad[] = {-1.0f, -1.0f, z, 1.0f, -1.0f, z, 1.0f, 0.0f, z,
-1.0f, -1.0f, z, 1.0f, 0.0f, z, -1.0f, 0.0f, z};
DrawTriangles(program, quad, 6);
}
// The whole of clip space at depth z.
void DrawEverything(unsigned int program, float z) {
const float quad[] = {-1.0f, -1.0f, z, 1.0f, -1.0f, z, 1.0f, 1.0f, z,
-1.0f, -1.0f, z, 1.0f, 1.0f, z, -1.0f, 1.0f, z};
DrawTriangles(program, quad, 6);
}
// Bottom and top halves of a `width` x `height` readback, inset away from the seam.
::testing::AssertionResult HalvesAre(const Image& image, const char* bottom, const char* top,
const std::string& when) {
const int w = image.Width(), h = image.Height();
auto lower = RegionIsMostly(image, w / 8, w - 1 - w / 8, h / 8, h / 2 - 1 - h / 8, bottom, 0.0,
when + " (bottom half)");
if (!lower) return lower;
return RegionIsMostly(image, w / 8, w - 1 - w / 8, h / 2 + h / 8, h - 1 - h / 8, top, 0.0,
when + " (top half)");
}
class ClipControlScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (!ExtensionAdvertised("GL_ARB_clip_control")) {
GTEST_SKIP() << "GL_ARB_clip_control is not advertised by this backend/driver";
}
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(m_program, 0u) << error;
}
void TearDown() override {
if (m_program == 0) return;
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
glDisable(GL_CULL_FACE);
glDisable(GL_DEPTH_TEST);
glDeleteProgram(m_program);
}
unsigned int m_program = 0;
};
} // namespace
TEST_F(ClipControlScenario, UpperLeftOriginMirrorsTheImageOnBothFramebufferKinds) {
if (!Ready() || IsSkipped()) return;
HeadlessGL& gl = Gl();
ColorFbo fbo = MakeColorFbo(64, 64);
ASSERT_NE(fbo.fbo, 0u);
for (const bool toDefault : {true, false}) {
for (const GLenum origin : {GLenum(GL_LOWER_LEFT), GLenum(GL_UPPER_LEFT)}) {
if (toDefault) {
BindDefaultFramebuffer();
} else {
BindFbo(fbo);
}
glDisable(GL_SCISSOR_TEST);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glClipControl(origin, GL_NEGATIVE_ONE_TO_ONE);
GLint reported = 0;
glGetIntegerv(GL_CLIP_ORIGIN, &reported);
EXPECT_EQ(static_cast<GLenum>(reported), origin);
DrawLowerHalf(m_program);
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
const Image image = toDefault ? ReadPixels(gl.Width(), gl.Height()) : ReadPixels(64, 64);
const std::string when = std::string(toDefault ? "default framebuffer" : "FBO") +
(origin == GL_UPPER_LEFT ? ", GL_UPPER_LEFT" : ", GL_LOWER_LEFT");
if (origin == GL_UPPER_LEFT) {
EXPECT_TRUE(HalvesAre(image, "blue", "red", when));
} else {
EXPECT_TRUE(HalvesAre(image, "red", "blue", when));
}
}
}
BindDefaultFramebuffer();
DestroyColorFbo(fbo);
EXPECT_EQ(FirstGLError(), 0u);
}
TEST_F(ClipControlScenario, CullingFollowsTheClipSpaceWindingUnderEitherOrigin) {
if (!Ready() || IsSkipped()) return;
ColorFbo fbo = MakeColorFbo(64, 64);
ASSERT_NE(fbo.fbo, 0u);
const float* triangles = kWoundHalves;
for (const bool toDefault : {true, false}) {
for (const GLenum origin : {GLenum(GL_LOWER_LEFT), GLenum(GL_UPPER_LEFT)}) {
if (toDefault) {
BindDefaultFramebuffer();
} else {
BindFbo(fbo);
}
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glEnable(GL_CULL_FACE); // GL_BACK, GL_CCW
glClipControl(origin, GL_NEGATIVE_ONE_TO_ONE);
DrawTriangles(m_program, triangles, 12);
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
glDisable(GL_CULL_FACE);
const Image image = toDefault ? ReadPixels(Gl().Width(), Gl().Height()) : ReadPixels(64, 64);
const int w = image.Width(), h = image.Height();
const std::string when = std::string(toDefault ? "default framebuffer" : "FBO") +
(origin == GL_UPPER_LEFT ? ", GL_UPPER_LEFT" : ", GL_LOWER_LEFT");
EXPECT_TRUE(RegionIsMostly(image, w / 8, w / 2 - 1 - w / 8, h / 8, h - 1 - h / 8, "red", 0.0,
when + ": counter-clockwise half is front-facing"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + w / 8, w - 1 - w / 8, h / 8, h - 1 - h / 8, "blue", 0.0,
when + ": clockwise half is culled"));
}
}
BindDefaultFramebuffer();
DestroyColorFbo(fbo);
EXPECT_EQ(FirstGLError(), 0u);
}
// gl_FrontFacing has to agree with culling, under either origin, on either framebuffer kind,
// and has to follow glFrontFace. A backend that keeps facing right by swapping its cull bits
// instead of its front face gets culling right and gl_FrontFacing backwards.
TEST_F(ClipControlScenario, FrontFacingFollowsTheWindingAndGlFrontFace) {
if (!Ready() || IsSkipped()) return;
std::string error;
const unsigned int facing = CompileProgram(kVS, kFacingFS, &error);
ASSERT_NE(facing, 0u) << error;
ColorFbo fbo = MakeColorFbo(64, 64);
ASSERT_NE(fbo.fbo, 0u);
for (const bool toDefault : {true, false}) {
for (const GLenum origin : {GLenum(GL_LOWER_LEFT), GLenum(GL_UPPER_LEFT)}) {
for (const GLenum frontFace : {GLenum(GL_CCW), GLenum(GL_CW)}) {
if (toDefault) {
BindDefaultFramebuffer();
} else {
BindFbo(fbo);
}
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glFrontFace(frontFace);
glClipControl(origin, GL_NEGATIVE_ONE_TO_ONE);
DrawTriangles(facing, kWoundHalves, 12);
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
glFrontFace(GL_CCW);
const Image image = toDefault ? ReadPixels(Gl().Width(), Gl().Height()) : ReadPixels(64, 64);
const int w = image.Width(), h = image.Height();
const std::string when = std::string(toDefault ? "default framebuffer" : "FBO") +
(origin == GL_UPPER_LEFT ? ", GL_UPPER_LEFT" : ", GL_LOWER_LEFT") +
(frontFace == GL_CW ? ", GL_CW" : ", GL_CCW");
const char* ccwColor = frontFace == GL_CCW ? "red" : "green";
const char* cwColor = frontFace == GL_CCW ? "green" : "red";
EXPECT_TRUE(RegionIsMostly(image, w / 8, w / 2 - 1 - w / 8, h / 8, h - 1 - h / 8, ccwColor, 0.0,
when + ": counter-clockwise half"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + w / 8, w - 1 - w / 8, h / 8, h - 1 - h / 8, cwColor,
0.0, when + ": clockwise half"));
}
}
}
BindDefaultFramebuffer();
DestroyColorFbo(fbo);
glDeleteProgram(facing);
EXPECT_EQ(FirstGLError(), 0u);
}
TEST_F(ClipControlScenario, ZeroToOneDepthModeMovesTheClipVolumeAndTheMapping) {
if (!Ready() || IsSkipped()) return;
ColorFbo fbo = MakeColorFbo(64, 64);
ASSERT_NE(fbo.fbo, 0u);
GLuint depth = 0;
glGenRenderbuffers(1, &depth);
glBindRenderbuffer(GL_RENDERBUFFER, depth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 64, 64);
BindFbo(fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depth);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
for (const GLenum mode : {GLenum(GL_NEGATIVE_ONE_TO_ONE), GLenum(GL_ZERO_TO_ONE)}) {
const bool zeroToOne = mode == GL_ZERO_TO_ONE;
const std::string when = zeroToOne ? "GL_ZERO_TO_ONE" : "GL_NEGATIVE_ONE_TO_ONE";
// z = -0.5 is inside [-w, w] and outside [0, w].
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glClipControl(GL_LOWER_LEFT, mode);
GLint reported = 0;
glGetIntegerv(GL_CLIP_DEPTH_MODE, &reported);
EXPECT_EQ(static_cast<GLenum>(reported), mode);
DrawEverything(m_program, -0.5f);
EXPECT_TRUE(RegionIsMostly(ReadPixels(64, 64), 8, 55, 8, 55, zeroToOne ? "blue" : "red", 0.0,
when + ": a primitive at z = -0.5"));
// Against a depth buffer cleared to 0.5: z = 0.25 is written as 0.25 under [0, 1] and as
// 0.625 under [-1, 1].
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glDepthMask(GL_TRUE);
glClearDepth(0.5);
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
DrawEverything(m_program, 0.25f);
EXPECT_TRUE(RegionIsMostly(ReadPixels(64, 64), 8, 55, 8, 55, zeroToOne ? "red" : "blue", 0.0,
when + ": a primitive at z = 0.25 against depth 0.5"));
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
}
glDisable(GL_DEPTH_TEST);
glClearDepth(1.0);
BindDefaultFramebuffer();
glDeleteRenderbuffers(1, &depth);
DestroyColorFbo(fbo);
EXPECT_EQ(FirstGLError(), 0u);
}
TEST_F(ClipControlScenario, ABlitUnderAnUpperLeftOriginIsNotMirrored) {
if (!Ready() || IsSkipped()) return;
HeadlessGL& gl = Gl();
ColorFbo fbo = MakeColorFbo(64, 64);
ASSERT_NE(fbo.fbo, 0u);
BindFbo(fbo);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawLowerHalf(m_program);
glClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo.fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, 64, 64, 0, 0, gl.Width(), gl.Height(), GL_COLOR_BUFFER_BIT, GL_NEAREST);
glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE);
BindDefaultFramebuffer();
EXPECT_TRUE(HalvesAre(ReadPixels(gl.Width(), gl.Height()), "red", "blue", "FBO blitted to the window"));
DestroyColorFbo(fbo);
EXPECT_EQ(FirstGLError(), 0u);
}
} // namespace MGITest
@@ -1337,6 +1337,52 @@ TEST(TessellationCapabilities, FollowTheVersionOrTheExtension) {
EXPECT_TRUE(es32.SupportsTessellationShader);
}
// glClipControl is forwarded only to a driver that both advertises GL_EXT_clip_control and
// resolves its entry point.
TEST(ClipControlCapabilities, NeedTheExtensionAndTheEntryPoint) {
auto withEntryPoint = MakeFakeGLESFunctions();
withEntryPoint.glClipControlEXT = [](GLenum, GLenum) {};
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
MobileGL::MG_External::GLESCapabilities noExtension;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(noExtension, withEntryPoint));
EXPECT_FALSE(noExtension.SupportsClipControl);
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_clip_control");
MobileGL::MG_External::GLESCapabilities noEntryPoint;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(noEntryPoint, MakeFakeGLESFunctions()));
EXPECT_FALSE(noEntryPoint.SupportsClipControl);
ResetFakeDriver();
g_fake.maxVertexSsboBlocks = 0;
g_fake.extensions.emplace_back("GL_EXT_clip_control");
MobileGL::MG_External::GLESCapabilities both;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(both, withEntryPoint));
EXPECT_TRUE(both.SupportsClipControl);
}
// DirectGLES advertises ARB_clip_control on the driver's extension; DirectVulkan always, since it
// applies clip control in its own position fixup.
TEST(CoreSurfaceAdvertisement, ClipControlFollowsTheBackendsRoute) {
const auto contains = [](const MobileGL::Vector<MobileGL::GLExtension>& extensions,
MobileGL::GLExtension wanted) {
return std::find(extensions.begin(), extensions.end(), wanted) != extensions.end();
};
EXPECT_FALSE(contains(MobileGL::MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false, false, false,
false, false),
MobileGL::E_GL_ARB_clip_control));
const MobileGL::MG_Backend::AdvertisedFeatureSupport clipControl{.ClipControl = true};
EXPECT_TRUE(contains(MobileGL::MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false, false, false,
false, false, clipControl),
MobileGL::E_GL_ARB_clip_control));
EXPECT_TRUE(contains(MobileGL::MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false, false,
false),
MobileGL::E_GL_ARB_clip_control));
}
// Minecraft 26.3 checks ARB_draw_indirect before it considers the already-advertised
// ARB_multi_draw_indirect, then separately requires ARB_base_instance before enabling its terrain
// indirect path. Pin both strings and, just as importantly, the non-zero firstInstance gate.
@@ -111,3 +111,23 @@ void main() {
}
)");
}
// glClipControl rides on the position fixup's two bits. An upper-left origin toggles the Y flip
// (an FBO gains one, the default framebuffer loses its own), a [0, 1] depth mode drops the
// depth remap, and nothing else - the rotation and gl_FragCoord bits - moves.
TEST(ClipControlTransformFlags, OriginTogglesTheFlipAndDepthModeDropsTheRemap) {
using Bit = MG_Backend::DirectVulkan::ProgramFactory::CompileOptionBit;
const auto apply = MG_Backend::DirectVulkan::ProgramFactory::ApplyClipControlToTransformFlags;
const Uint32 fbo = static_cast<Uint32>(Bit::PositionZRemap);
const Uint32 window = static_cast<Uint32>(Bit::PositionZRemap) | static_cast<Uint32>(Bit::PositionYFlip) |
static_cast<Uint32>(Bit::FragCoordYFlip) | static_cast<Uint32>(Bit::SurfaceRotate90);
EXPECT_EQ(apply(fbo, GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE), fbo);
EXPECT_EQ(apply(window, GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE), window);
EXPECT_EQ(apply(fbo, GL_UPPER_LEFT, GL_NEGATIVE_ONE_TO_ONE), fbo | static_cast<Uint32>(Bit::PositionYFlip));
EXPECT_EQ(apply(window, GL_UPPER_LEFT, GL_NEGATIVE_ONE_TO_ONE),
window & ~static_cast<Uint32>(Bit::PositionYFlip));
EXPECT_EQ(apply(fbo, GL_LOWER_LEFT, GL_ZERO_TO_ONE), 0u);
EXPECT_EQ(apply(window, GL_UPPER_LEFT, GL_ZERO_TO_ONE),
static_cast<Uint32>(Bit::FragCoordYFlip) | static_cast<Uint32>(Bit::SurfaceRotate90));
}
@@ -661,6 +661,7 @@ namespace MobileGL::MG_Util::BackendLoader {
INIT_GLES_FUNC(glMapBufferOES)
INIT_GLES_FUNC_OPTIONAL(glPolygonModeNV)
INIT_GLES_FUNC_OPTIONAL(glPolygonModeANGLE)
INIT_GLES_FUNC_OPTIONAL(glClipControlEXT)
INIT_GLES_FUNC_OPTIONAL(glColorMaskiEXT)
INIT_GLES_FUNC_OPTIONAL(glColorMaskiOES)
// Extension-only multi-draw entry points. eglGetProcAddress may legally return a
@@ -1124,6 +1125,7 @@ namespace MobileGL::MG_Util::BackendLoader {
// comes out false, so a stub pointer counting as support would silently break the draws.
Bool hasBaseInstanceExtension = false;
Bool hasTessellationShaderExtension = false;
Bool hasClipControlExtension = false;
for (GLint i = 0; i < extCount; ++i) {
const char* extension = (const char*)glesFuncs.glGetStringi(GL_EXTENSIONS, i);
if (extension) {
@@ -1215,6 +1217,9 @@ namespace MobileGL::MG_Util::BackendLoader {
if (std::strcmp(extension, "GL_EXT_clip_cull_distance") == 0) {
caps.SupportsClipDistance = true;
}
if (std::strcmp(extension, "GL_EXT_clip_control") == 0) {
hasClipControlExtension = true;
}
if (std::strcmp(extension, "GL_OES_viewport_array") == 0) {
caps.SupportsViewportArray = true;
}
@@ -1287,6 +1292,9 @@ namespace MobileGL::MG_Util::BackendLoader {
glesFuncs.glDrawElementsBaseVertex != nullptr;
caps.SupportsTessellationShader = (esAtLeast32 || (esAtLeast31 && hasTessellationShaderExtension)) &&
glesFuncs.glPatchParameteri != nullptr;
// The string AND the pointer: eglGetProcAddress may hand back a stub for a name the
// driver does not implement.
caps.SupportsClipControl = hasClipControlExtension && glesFuncs.glClipControlEXT != nullptr;
caps.SupportsComputeShader = esAtLeast31 && glesFuncs.glDispatchCompute != nullptr &&
glesFuncs.glMemoryBarrier != nullptr &&
glesFuncs.glCreateShader != nullptr &&
@@ -1319,6 +1327,7 @@ namespace MobileGL::MG_Util::BackendLoader {
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(" clip control (EXT_clip_control): %s", caps.SupportsClipControl ? "yes" : "no");
MGLOG_I(" viewport array (OES_viewport_array; gl_ViewportIndex collapses to viewport 0 when absent): %s",
caps.SupportsViewportArray ? "yes" : "no");
{
@@ -650,6 +650,8 @@ namespace MobileGL {
// Extension aliases for glPolygonMode (GLES core has none) and indexed glColorMaski.
GL_FUNC_TYPEDEF(void, glPolygonModeNV, GLenum face, GLenum mode)
GL_FUNC_TYPEDEF(void, glPolygonModeANGLE, GLenum face, GLenum mode)
// GL_EXT_clip_control (ES has no core glClipControl).
GL_FUNC_TYPEDEF(void, glClipControlEXT, GLenum origin, GLenum depth)
GL_FUNC_TYPEDEF(void, glColorMaskiEXT, GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a)
GL_FUNC_TYPEDEF(void, glColorMaskiOES, GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a)
@@ -1066,6 +1068,7 @@ namespace MobileGL {
GL_FUNC_DECL(glMapBufferOES)
GL_FUNC_DECL(glPolygonModeNV)
GL_FUNC_DECL(glPolygonModeANGLE)
GL_FUNC_DECL(glClipControlEXT)
GL_FUNC_DECL(glColorMaskiEXT)
GL_FUNC_DECL(glColorMaskiOES)
@@ -1117,6 +1120,9 @@ namespace MobileGL {
// Tessellation stages: ES 3.2 core, or GL_EXT_tessellation_shader on 3.1, with
// glPatchParameteri resolved either way.
Bool SupportsTessellationShader = false;
// GL_EXT_clip_control advertised and glClipControlEXT resolved: the driver applies a
// clip-space origin and depth mode, so the application's glClipControl is forwarded.
Bool SupportsClipControl = false;
// GL_EXT_texture_view / GL_OES_texture_view: two ES texture names sharing one
// storage, i.e. the only way DirectGLES can answer glTextureView at all. ES never
// made this core - not even in 3.2 - so unlike every other capability here there is