diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index 9a41c53f..85a88130 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -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 diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index ab913c77..f057bd3e 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -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. diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index dc80437c..400973eb 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -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(m_clipOrigin), static_cast(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 diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 3cedf0da..65a00426 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -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. diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index eae37ffd..03871b14 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -75,6 +75,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { ZeroBaseVertex = 1 << 8, }; using CompileOptionFlags = Flags; + + // 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(CompileOptionBit::PositionYFlip); + if (depthMode == GL_ZERO_TO_ONE) flags &= ~static_cast(CompileOptionBit::PositionZRemap); + return flags; + } using HashType = Uint64; // The gl_PerVertex members a pass-through tessellation control stage may have to carry, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc index efdcf9ab..bfa66490 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc @@ -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; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 57be1d6c..0481b349 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -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). diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClipControlScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClipControlScenario.cpp new file mode 100644 index 00000000..b7475696 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClipControlScenario.cpp @@ -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 +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in 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(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(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(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(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(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 diff --git a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp index 366827af..d10294fd 100644 --- a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp +++ b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp @@ -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& 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. diff --git a/MobileGL/MG_Test/ShaderTranspiler/FragCoordYFlipTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/FragCoordYFlipTest.cpp index 15135887..9fc7b291 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/FragCoordYFlipTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/FragCoordYFlipTest.cpp @@ -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(Bit::PositionZRemap); + const Uint32 window = static_cast(Bit::PositionZRemap) | static_cast(Bit::PositionYFlip) | + static_cast(Bit::FragCoordYFlip) | static_cast(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(Bit::PositionYFlip)); + EXPECT_EQ(apply(window, GL_UPPER_LEFT, GL_NEGATIVE_ONE_TO_ONE), + window & ~static_cast(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(Bit::FragCoordYFlip) | static_cast(Bit::SurfaceRotate90)); +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 58e0dfa4..8fb61bd4 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -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"); { diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index dd5b15eb..b3813eb6 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -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