diff --git a/CMakeLists.txt b/CMakeLists.txt index 3efa7d2a..d2909e7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -444,6 +444,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.cpp MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp diff --git a/MobileGL/BackendCaps.h b/MobileGL/BackendCaps.h index 1ec67a4e..3d1e2ae7 100644 --- a/MobileGL/BackendCaps.h +++ b/MobileGL/BackendCaps.h @@ -214,16 +214,16 @@ namespace MobileGL { Int MaxComputeImageUniforms = 8; Int MaxDrawBuffers = 8; Int MaxColorAttachments = 8; - // GL_MAX_CLIP_DISTANCES. Zero is a legal answer here, not a placeholder, and a - // backend that cannot host a clip distance MUST report it: advertising eight the - // backend will refuse does not make gl_ClipDistance work, it only moves the failure - // from an honest "unsupported" at query time to a backend shader-compile error the - // frontend never surfaces, after which every draw with that program silently renders - // nothing. DirectGLES fills it from GL_EXT_clip_cull_distance, DirectVulkan from the - // shaderClipDistance device feature. The DEFAULT stays at the GL 4.3 core minimum - // because it describes the no-backend case (standalone shader compiles, unit tests), - // where there is no device to be honest about and BuildTBuiltInResource still has to - // hand glslang a workable gl_MaxClipDistances. + // GL_MAX_CLIP_DISTANCES. What the backend can actually CLIP with, natively or through + // EmulatesClipDistance below - never a number the backend would then refuse: + // advertising eight that no stage can honor only moves the failure from query time to + // a backend shader-compile error the frontend never surfaces. DirectGLES fills it from + // GL_EXT_clip_cull_distance, DirectVulkan from the shaderClipDistance device feature, + // and both answer 8 when they emulate. Zero stays a legal answer for a backend that + // neither hosts nor emulates the built-in (MOBILEGL_CLIP_DISTANCE_EMULATION=0 on such + // a device). The DEFAULT is the GL 4.3 core minimum because it describes the + // no-backend case (standalone shader compiles, unit tests), where + // BuildTBuiltInResource still has to hand glslang a workable gl_MaxClipDistances. Int MaxClipDistances = 8; // GL_MAX_CULL_DISTANCES and GL_MAX_COMBINED_CLIP_AND_CULL_DISTANCES, under exactly // the contract stated for MaxClipDistances above: ZERO IS A LEGAL ANSWER and a @@ -349,6 +349,15 @@ namespace MobileGL { Bool SupportsTessellationPointSize = true; // The geometry-stage twin (OpCapability GeometryPointSize). Bool SupportsGeometryPointSize = true; + // The backend cannot clip with gl_ClipDistance natively - no + // GL_EXT_clip_cull_distance, an extension the POST clip probe caught not clipping or + // not honoring the per-distance enables, or shaderClipDistance == VK_FALSE - so + // ProgramSpirvTask lowers the built-in to varyings plus a fragment discard + // (ShaderCompiler::EmulateClipDistanceForProgram) and the source pipeline declares the + // reserved enables uniform the draw path feeds. MOBILEGL_CLIP_DISTANCE_EMULATION + // overrides the detection in either direction at backend init. Defaults FALSE: true + // ARMS a rewrite, so the no-backend answer is the one that leaves modules untouched. + Bool EmulatesClipDistance = false; Uint64 MaxShaderStorageBlockSize = 128 * 1024 * 1024; Uint32 SubgroupSize = 0; Uint32 SubgroupSupportedStages = 0; diff --git a/MobileGL/Config.h b/MobileGL/Config.h index a0b378d4..1acd05fe 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -237,6 +237,14 @@ namespace MobileGL::MG_Config { // negative control). Cross-backend by design: the demotion runs in the shared // phase-B chain, so one switch covers both. See DemotePointSizePass. QuirkOverride PointSizeDemotion = QuirkOverride::Auto; + // MOBILEGL_CLIP_DISTANCE_EMULATION: lower gl_ClipDistance to ordinary varyings plus a + // fragment discard (ShaderCompiler::EmulateClipDistanceForProgram) on a backend that + // cannot clip natively - no GL_EXT_clip_cull_distance or a driver the POST clip probe + // caught misbehaving (DirectGLES), shaderClipDistance == VK_FALSE (DirectVulkan). + // Auto arms it exactly there. ForceOn exercises it on a healthy driver (the pinned + // integration lane); ForceOff restores the native-or-nothing answer, which reports + // GL_MAX_CLIP_DISTANCES = 0 on a device without native support. + QuirkOverride ClipDistanceEmulation = QuirkOverride::Auto; // MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write // GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never // flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 2a1e91da..7c17cd96 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -402,6 +402,7 @@ namespace MobileGL::MG_ConfigLoader { features.EsprytAtomicSubgroupScratch = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_ATOMIC_SUBGROUP_SCRATCH"); features.EsprytInvalidImagePlaceholder = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_INVALID_IMAGE_PLACEHOLDER"); features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION"); + features.ClipDistanceEmulation = QueryEnvQuirkOverride("MOBILEGL_CLIP_DISTANCE_EMULATION"); features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH"); features.EsprytDisableUboRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UBO_RING"); features.EsprytDisableUnpackRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UNPACK_RING"); diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index 335049f9..a6596f88 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -1996,12 +1996,18 @@ namespace MobileGL::MG_Backend::DirectGLES { } m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers; m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments; - m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances; + // Eight when emulated - the GL 3.0+ minimum, and what EmulateClipDistancePass carries - + // else the driver's own number (0 without the extension and with the emulation forced + // off: the native-or-nothing answer). + m_dynamicParameters.EmulatesClipDistance = m_GLESCapabilities.EmulatesClipDistance; + m_dynamicParameters.MaxClipDistances = + m_GLESCapabilities.EmulatesClipDistance ? 8 : m_GLESCapabilities.MaxClipDistances; // The loader already gated both on GL_EXT_clip_cull_distance and left 0 without it, which // is the answer that keeps glslang from accepting a gl_CullDistance the ESSL compiler // would reject. m_dynamicParameters.MaxCullDistances = m_GLESCapabilities.MaxCullDistances; - m_dynamicParameters.MaxCombinedClipAndCullDistances = m_GLESCapabilities.MaxCombinedClipAndCullDistances; + m_dynamicParameters.MaxCombinedClipAndCullDistances = + std::max(m_GLESCapabilities.MaxCombinedClipAndCullDistances, m_dynamicParameters.MaxClipDistances); m_dynamicParameters.MaxViewports = m_GLESCapabilities.MaxViewports; // Whatever the driver said about which vertex supplies gl_Layer, and GL_UNDEFINED_VERTEX // for gl_ViewportIndex on every driver without GL_OES_viewport_array - which is both test diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLESState.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLESState.cpp index af809858..bd5272d8 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLESState.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLESState.cpp @@ -514,7 +514,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - if (tailSpanDirty && g_GLESCapabilities.SupportsClipDistance) { + if (tailSpanDirty && g_GLESCapabilities.SupportsClipDistance && !g_GLESCapabilities.EmulatesClipDistance) { // 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 diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index dc2d3a75..1c574ecd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -1343,8 +1343,34 @@ namespace MobileGL::MG_Backend::DirectVulkan { // by every device, but declaring ClipDistance in a module needs the shaderClipDistance // FEATURE, which VulkanRenderer enables exactly where the physical device has it. Without // it the limit describes a capacity no shader may use, so report none. + // + // Without the feature the built-in is emulated instead (EmulateClipDistancePass: varyings + // plus a fragment discard, which needs nothing from the device), so the frontend can + // still answer the GL 3.0+ minimum of eight. Every Mali device measured so far reports + // shaderClipDistance = VK_FALSE. MOBILEGL_CLIP_DISTANCE_EMULATION=1 arms it on a device + // that has the feature (the pinned integration lane); =0 restores the plain zero. + Bool emulateClipDistance = !m_vulkanCaps.SupportsShaderClipDistance; + switch (MG_Config::Features.ClipDistanceEmulation) { + case MG_Config::QuirkOverride::ForceOn: + emulateClipDistance = true; + MGLOG_I("DirectVulkan: MOBILEGL_CLIP_DISTANCE_EMULATION=1 - emulating gl_ClipDistance even though " + "the device %s shaderClipDistance", + m_vulkanCaps.SupportsShaderClipDistance ? "has" : "lacks"); + break; + case MG_Config::QuirkOverride::ForceOff: + emulateClipDistance = false; + MGLOG_I("DirectVulkan: MOBILEGL_CLIP_DISTANCE_EMULATION=0 - no gl_ClipDistance emulation"); + break; + case MG_Config::QuirkOverride::Auto: + default: + break; + } + m_dynamicParameters.EmulatesClipDistance = emulateClipDistance; m_dynamicParameters.MaxClipDistances = - m_vulkanCaps.SupportsShaderClipDistance ? std::max(m_vulkanCaps.MaxClipDistances, 0) : 0; + emulateClipDistance ? 8 + : (m_vulkanCaps.SupportsShaderClipDistance ? std::max(m_vulkanCaps.MaxClipDistances, 0) : 0); + MGLOG_I("DirectVulkan: gl_ClipDistance %s (GL_MAX_CLIP_DISTANCES = %d)", + emulateClipDistance ? "EMULATED (varyings + fragment discard)" : "native", m_dynamicParameters.MaxClipDistances); // The cull pair, gated on its own feature. shaderCullDistance is separate from // shaderClipDistance and VulkanRenderer enables it independently, so it gets its own // gate rather than riding on the clip one. @@ -1357,7 +1383,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { (m_vulkanCaps.SupportsShaderClipDistance || m_vulkanCaps.SupportsShaderCullDistance) ? std::max({m_vulkanCaps.MaxCombinedClipAndCullDistances, m_dynamicParameters.MaxClipDistances, m_dynamicParameters.MaxCullDistances}) - : 0; + : m_dynamicParameters.MaxClipDistances; m_dynamicParameters.MaxViewports = m_vulkanCaps.MaxViewports; // Assigned explicitly rather than left to the struct's defaults, like every other // parameter here, so a second fill cannot inherit a stale value. GL_UNDEFINED_VERTEX is diff --git a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp index b44e7edb..fe8e743c 100644 --- a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp +++ b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp @@ -108,6 +108,21 @@ namespace MobileGL::MG_Impl::GLImpl { program->WriteReservedNumSamples(static_cast(std::max(ResolveDrawFramebufferSampleCount(), 1))); } + // The GL_CLIP_DISTANCE0..7 enables, for a program whose clip distances are emulated (see + // EmulateClipDistancePass): the producing stage selects 1.0 for every disabled distance, so + // the mask is a property of the render state at THIS draw, not of the program. Same cost + // model as the sample count above. + static void PublishClipDistanceEnables(const SharedPtr& program) { + if (!program || !program->UsesReservedClipDistanceEnables()) return; + Uint32 enables = 0; + for (Uint i = 0; i < 8; ++i) { + const auto cap = + static_cast(static_cast(CapabilityInput::ClipDistance0) + i); + if (MG_State::pGLContext->IsCapabilityEnabled(cap)) enables |= 1u << i; + } + program->WriteReservedClipDistanceEnables(enables); + } + // The one funnel every drawing command passes through. Order is load-bearing: validate first // (a rejected draw must leave state alone), then publish the sample count - which reads the // DRAW FRAMEBUFFER binding, so it has to run after the caller's framebuffer state is settled @@ -133,6 +148,7 @@ namespace MobileGL::MG_Impl::GLImpl { const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); if (!ValidateResolvedProgramForDraw(currentProgram, functionName)) return false; PublishDrawFramebufferSampleCount(currentProgram); + PublishClipDistanceEnables(currentProgram); return true; } diff --git a/MobileGL/MG_IntegrationTest/Harness/strict-known-limitations.md b/MobileGL/MG_IntegrationTest/Harness/strict-known-limitations.md index 60b39ebe..9332e156 100644 --- a/MobileGL/MG_IntegrationTest/Harness/strict-known-limitations.md +++ b/MobileGL/MG_IntegrationTest/Harness/strict-known-limitations.md @@ -7,9 +7,11 @@ Configuration-specific controls are selected separately and must pass without sk ## Unresolved behavior - `ClipDistanceScenario.ADisabledClipDistanceRemovesNothing` and - `TheEnablesAreIndependentPerDistance`: per-distance enable semantics are missing - on the tested GLES and Vulkan paths. Neither backend supplies a passing control - on local llvmpipe/lavapipe. The strict list names the GLES and Magma Fm entries. + `TheEnablesAreIndependentPerDistance`: Magma's NATIVE clip distances (lavapipe, + Adreno) still ignore the GL_CLIP_DISTANCEi enables - Vulkan has no such state. + Every emulating backend has them (EmulateClipDistancePass); Espryt on llvmpipe + now emulates, because its native probe catches llvmpipe clipping by disabled + distances, so the GLES entries pass wherever the emulation is armed. - `PartialUploadOverGpuContentScenario.OneDimensionalArraySubImageKeepsTheGpuClearedLayer`: the Vulkan path cannot attach the 1D array layer. GLES passes, which does not establish Vulkan coverage. diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp index ecea596d..d577a6cd 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClipDistanceScenario.cpp @@ -26,16 +26,15 @@ // 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. // -// HONEST LIMIT OF THIS FILE IN CI. Of the four cases, only EnableIsObservableThroughIsEnabled is -// falsifiable on the software rasterizers every automated lane runs on. llvmpipe and lavapipe -// clip by EVERY declared gl_ClipDistance regardless of the enables, so -// AnEnabledClipDistanceRemovesTheNegativeHalf goes green there against the broken tree as well, -// and the two cases that need real per-distance semantics skip (see -// DriverHonorsPerDistanceEnables). What actually pins the behavior is Adreno, through -// KHR-GLxx.clip_distance.functional - whose "without dynamic redeclaration" variants declare all -// gl_MaxClipDistances slots and enable only the first N, i.e. exactly the subset semantics these -// skipped cases assert. Read a green CI run here as "the state survives the frontend", not as -// "clipping is correct"; the second claim is a device claim. +// WHAT CI CAN AND CANNOT FALSIFY. llvmpipe's ES driver clips by EVERY written gl_ClipDistance +// regardless of the enables, which the DirectGLES loader's native clip probe catches - so on +// that lane Espryt EMULATES clip distances (EmulateClipDistancePass: varyings, a fragment +// discard and a per-draw enables uniform), and the per-distance cases below run and pass +// there. That is the emulation every Mali device takes. lavapipe hosts shaderClipDistance, so +// Magma clips natively, Vulkan has no per-distance enable state, and the two per-distance cases +// still skip on that lane (MOBILEGL_CLIP_DISTANCE_EMULATION=1 runs them green through the +// emulation). The native ES path - Adreno, which honors GL_CLIP_DISTANCEi_EXT - is pinned on +// the device, through KHR-GLxx.clip_distance.functional. // // Every case disables all eight distances on entry rather than assuming they start off: // XfbAfterClipDistanceScenario deliberately leaves one enabled for the rest of the process, and @@ -83,13 +82,33 @@ void main() { const char* const kFragmentSource = R"(#version 400 core out vec4 fragColor; void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + // The field report's shape (Mali-G615 / Espryt, where GL_MAX_CLIP_DISTANCES used to be 0 + // and this did not even compile): a #version 330 core stage writing all eight distances + // from uniform planes, implicitly sized by use. + const char* const kEightPlaneVertexSource = R"(#version 330 core +uniform vec4 u_ClipPlane[8]; +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + vec4 eyePos = vec4(positions[gl_VertexID], 0.0, 1.0); + gl_Position = eyePos; + gl_ClipDistance[0] = dot(u_ClipPlane[0], eyePos); + gl_ClipDistance[1] = dot(u_ClipPlane[1], eyePos); + gl_ClipDistance[2] = dot(u_ClipPlane[2], eyePos); + gl_ClipDistance[3] = dot(u_ClipPlane[3], eyePos); + gl_ClipDistance[4] = dot(u_ClipPlane[4], eyePos); + gl_ClipDistance[5] = dot(u_ClipPlane[5], eyePos); + gl_ClipDistance[6] = dot(u_ClipPlane[6], eyePos); + gl_ClipDistance[7] = dot(u_ClipPlane[7], eyePos); +} )"; class ClipDistanceScenario : public ScenarioTest { protected: - GLuint BuildProgram() { + GLuint BuildProgram(const char* vertexSource = kVertexSource) { const GLuint vs = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vs, 1, &kVertexSource, nullptr); + glShaderSource(vs, 1, &vertexSource, nullptr); glCompileShader(vs); GLint compiled = 0; glGetShaderiv(vs, GL_COMPILE_STATUS, &compiled); @@ -178,11 +197,12 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); } // always. DirectVulkan therefore clips by a disabled distance. // - llvmpipe's 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. + // EmulateClipDistancePass gives those semantics to every backend that EMULATES clip + // distances (the disabled slots are forced to +1 from a per-draw uniform, so nothing + // joins the pipeline key), and the DirectGLES loader arms that emulation on a driver + // whose native clip distances ignore the enables. Magma's native path does not apply + // the enables yet. 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 DriverHonorsPerDistanceEnables(GLuint program, GLuint vao) const { for (int i = 0; i < 8; ++i) { glDisable(static_cast(GL_CLIP_DISTANCE0 + i)); @@ -403,4 +423,91 @@ void main() { fragColor = vec4(0.0, 1.0, 0.0, 1.0); } gl.EndFrame(); } + // GL 3.0+ requires eight clip distances, and the field report was a stack answering zero: a + // #version 330 core stage writing gl_ClipDistance[0..7] did not compile. Every backend now + // either hosts eight or emulates them (EmulateClipDistancePass), so this case requires eight + // and does not skip. Distance 2 clips the left half, the other seven are positive, and all + // eight are enabled - which clips identically whether or not the stack honors per-distance + // enables. The per-distance half (distance 5 negative on the right but DISABLED) only runs + // where the stack honors them, which every emulating backend does by construction. + TEST_F(ClipDistanceScenario, EightDistancesFromUniformPlanesClipLikeTheReport) { + if (!Ready()) return; + GLint maxClipDistances = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &maxClipDistances); + ASSERT_GE(maxClipDistances, 8) << "GL 3.0+ requires GL_MAX_CLIP_DISTANCES >= 8"; + 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(kEightPlaneVertexSource); + ASSERT_NE(program, 0u) << "the eight-plane gl_ClipDistance program did not build: " << BuildLog(); + const GLint planes = glGetUniformLocation(program, "u_ClipPlane"); + ASSERT_GE(planes, 0); + + 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); + + // w = 1 everywhere, so (0,0,0,1) is a distance of +1 and keeps everything. + GLfloat values[8 * 4]; + for (int i = 0; i < 8; ++i) { + values[i * 4 + 0] = 0.0f; + values[i * 4 + 1] = 0.0f; + values[i * 4 + 2] = 0.0f; + values[i * 4 + 3] = 1.0f; + } + values[2 * 4 + 0] = 1.0f; // distance 2 = x: negative on the left half + values[2 * 4 + 3] = 0.0f; + values[5 * 4 + 0] = -1.0f; // distance 5 = -x: negative on the right half + values[5 * 4 + 3] = 0.0f; + glUseProgram(program); + glUniform4fv(planes, 8, values); + + DisableEveryClipDistance(); + for (int i = 0; i < 8; ++i) { + if (i != 5) glEnable(static_cast(GL_CLIP_DISTANCE0 + i)); + } + // First with distance 5 enabled too: both halves clipped, by different distances. + glEnable(static_cast(GL_CLIP_DISTANCE0 + 5)); + DrawClippedTriangle(program, vao); + unsigned char left[4] = {0, 0, 0, 0}; + unsigned char right[4] = {0, 0, 0, 0}; + PixelAt(width / 4, height / 2, left); + PixelAt(width - 1 - width / 4, height / 2, right); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(IsRed(left)) << "distance 2 is negative on the left and enabled"; + EXPECT_TRUE(IsRed(right)) << "distance 5 is negative on the right and enabled"; + + // Then distance 5 disabled: on a stack with per-distance enables the right half returns. + glDisable(static_cast(GL_CLIP_DISTANCE0 + 5)); + DrawClippedTriangle(program, vao); + PixelAt(width / 4, height / 2, left); + PixelAt(width - 1 - width / 4, height / 2, right); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(IsRed(left)) << "distance 2 is still enabled"; + const bool rightKept = IsGreen(right); + + DisableEveryClipDistance(); + const GLuint control = BuildProgram(); + const bool honorsEnables = control != 0 && DriverHonorsPerDistanceEnables(control, vao); + if (control != 0) glDeleteProgram(control); + if (honorsEnables) { + EXPECT_TRUE(rightKept) << "distance 5 is negative on the right but GL_CLIP_DISTANCE5 is disabled, so the " + "right half must survive"; + } + + glUseProgram(0); + glBindVertexArray(0); + glDeleteProgram(program); + glDeleteVertexArrays(1, &vao); + gl.EndFrame(); + } + } // namespace MGITest diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index d27b6821..0738227c 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -389,7 +389,7 @@ F(FragmentInterpolationOffsetBits) F(SupportsWideLines) \ F(SupportsDistinctDepthStencilAttachments) F(PerLayerFramebufferAttachmentTargets) \ F(SupportsShaderFloat64) F(SupportsFloat64VertexAttributes) F(SupportsTessellationPointSize) \ - F(SupportsGeometryPointSize) F(MaxShaderStorageBlockSize) F(SubgroupSize) \ + F(SupportsGeometryPointSize) F(EmulatesClipDistance) F(MaxShaderStorageBlockSize) F(SubgroupSize) \ F(SubgroupSupportedStages) F(SubgroupSupportedFeatures) F(SamplePositions) \ F(SamplePositionCountMask) F(SubgroupQuadOperationsInAllStages) \ F(GpuVendor) F(SupportsFragmentShadingRate) F(SupportsPrimitiveFragmentShadingRate) \ diff --git a/MobileGL/MG_Pipe/generated/PipeWireLayout.inc b/MobileGL/MG_Pipe/generated/PipeWireLayout.inc index 0a42b5ef..4bdc884a 100644 --- a/MobileGL/MG_Pipe/generated/PipeWireLayout.inc +++ b/MobileGL/MG_Pipe/generated/PipeWireLayout.inc @@ -660,6 +660,7 @@ inline constexpr WireLayoutMember kMGPipeWireLayoutMembers[] = { {"DynamicBackendParameters.SupportsFloat64VertexAttributes", offsetof(DynamicBackendParameters, SupportsFloat64VertexAttributes), sizeof(((DynamicBackendParameters*)nullptr)->SupportsFloat64VertexAttributes)}, {"DynamicBackendParameters.SupportsTessellationPointSize", offsetof(DynamicBackendParameters, SupportsTessellationPointSize), sizeof(((DynamicBackendParameters*)nullptr)->SupportsTessellationPointSize)}, {"DynamicBackendParameters.SupportsGeometryPointSize", offsetof(DynamicBackendParameters, SupportsGeometryPointSize), sizeof(((DynamicBackendParameters*)nullptr)->SupportsGeometryPointSize)}, + {"DynamicBackendParameters.EmulatesClipDistance", offsetof(DynamicBackendParameters, EmulatesClipDistance), sizeof(((DynamicBackendParameters*)nullptr)->EmulatesClipDistance)}, {"DynamicBackendParameters.MaxShaderStorageBlockSize", offsetof(DynamicBackendParameters, MaxShaderStorageBlockSize), sizeof(((DynamicBackendParameters*)nullptr)->MaxShaderStorageBlockSize)}, {"DynamicBackendParameters.SubgroupSize", offsetof(DynamicBackendParameters, SubgroupSize), sizeof(((DynamicBackendParameters*)nullptr)->SubgroupSize)}, {"DynamicBackendParameters.SubgroupSupportedStages", offsetof(DynamicBackendParameters, SubgroupSupportedStages), sizeof(((DynamicBackendParameters*)nullptr)->SubgroupSupportedStages)}, diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h index fbbe1461..398faabc 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -337,6 +337,12 @@ namespace MobileGL::MG_State::GLState { // gate has to be answerable without joining the SPIR-V job, or every draw of every // program would pay a join to discover it has nothing to write. Bool usesReservedNumSamples = false; + // A pre-rasterization stage of this program declared the reserved clip-distance enables + // uniform (ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME), which the source + // pipeline does only on a backend that emulates gl_ClipDistance, so the draw path owes it + // the GL_CLIP_DISTANCE0..7 enables before every draw. Phase A for the reason + // usesReservedNumSamples is. + Bool usesReservedClipDistanceEnables = false; // A shader of this program declared an `es` #version (ShaderTranspiler::FindEsOrigin). // ESSL fixes an image uniform's unit at its binding qualifier, so glUniform* on one is // INVALID_OPERATION for such a program and legal for a desktop one. @@ -415,6 +421,8 @@ namespace MobileGL::MG_State::GLState { // uniformOffsets, because the member has no GL location at all: the link task keeps // it out of the GL-visible uniform index space so no application can see or write it. Uint reservedNumSamplesOffset = kInvalidUniformOffset; + // The same, for the reserved clip-distance enables uniform. + Uint reservedClipDistanceEnablesOffset = kInvalidUniformOffset; // False for a program whose SPIR-V was never produced (phase B canceled at // teardown or by a relink) or whose optimizer run failed. GL has no way to // retract a LINK_STATUS it already reported true, so such a program stays @@ -443,6 +451,11 @@ namespace MobileGL::MG_State::GLState { // deliberately keeps answering "gl_PointSize": demotion happens after phase A, // so every query keeps the truthful GL spelling. Bool pointSizeDemoted = false; + // Whether these modules had gl_ClipDistance lowered to varyings plus a fragment discard + // (ShaderCompiler::EmulateClipDistanceForProgram) because the backend cannot clip + // natively. Recorded for the same reason pointSizeDemoted is: the final bytes no longer + // declare the capability that armed it. + Bool clipDistanceEmulated = false; }; // ---- the archive field tables (ARCHITECTURE.md ยง7): ONE table per type, serving both directions ---- @@ -579,6 +592,7 @@ namespace MobileGL::MG_State::GLState { v("uniformBlocksWithoutBinding", a.uniformBlocksWithoutBinding); v("activeUniformCount", a.activeUniformCount); v("usesReservedNumSamples", a.usesReservedNumSamples); + v("usesReservedClipDistanceEnables", a.usesReservedClipDistanceEnables); v("esOrigin", a.esOrigin); v("maxUniformLocation", a.maxUniformLocation); v("uniformNameMaxLength", a.uniformNameMaxLength); @@ -614,10 +628,12 @@ namespace MobileGL::MG_State::GLState { v("uniformOffsets", a.uniformOffsets); v("globalUboScratch", a.globalUboScratch); v("reservedNumSamplesOffset", a.reservedNumSamplesOffset); + v("reservedClipDistanceEnablesOffset", a.reservedClipDistanceEnablesOffset); v("spirvStatus", a.spirvStatus); v("nativeFloat64", a.nativeFloat64); v("pointSizeDemoted", a.pointSizeDemoted); - } // 8 fields + v("clipDistanceEmulated", a.clipDistanceEmulated); + } // 10 fields // ---- trip wires ---- // TypeFacts is a POD on every ABI: 13 Bool + 3 bytes of padding + 7 x 4-byte scalars. @@ -635,7 +651,7 @@ namespace MobileGL::MG_State::GLState { // P7 OQ-8 added storageBlocks, one more Vector, in the disaggregated build ONLY. The pull // number below is the one G1 measures and it has not moved. #define MGL_LINKARTIFACTS_SIZE 1080 -#define MGL_SPIRVARTIFACTS_SIZE 88 +#define MGL_SPIRVARTIFACTS_SIZE 96 #elif defined(_LIBCPP_VERSION) && (SIZE_MAX == UINT64_MAX) && defined(MGL_ARTIFACT_SIZES_LIBCXX_PINNED) // The integrator pins these from the NDK build (brief C.4); until then this branch is inert. #endif diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index a1d6c365..d88d3ec3 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -1135,7 +1135,9 @@ namespace MobileGL::MG_State::GLState { for (const ProgramObject::XfbVarying& varying : artifacts.xfbVaryings) { if (varying.name == "gl_PointSize") { spirvHandoff.captureRequestsPointSize = true; - break; + } + if (varying.name.rfind("gl_ClipDistance", 0) == 0) { + spirvHandoff.captureRequestsClipDistance = true; } } // Phase B pairs this with its own SpirvArtifacts to insert the completed front end. @@ -1501,6 +1503,21 @@ namespace MobileGL::MG_State::GLState { }; for (Int i = 0; i < tProgramUniformCount; i++) { const auto& uniform = artifacts.program->getUniform(i); + // The clip-distance enables InjectClipDistanceEnablesShim declared: MobileGL's own + // per-draw input to the emulation, hidden from the GL surface on the same terms as + // the gl_NumSamples stand-in below. Checked BEFORE the dead-uniform filter on + // purpose: no application code reads it - only EmulateClipDistancePass does, after + // this reflection ran - so glslang reports it with stages == 0, and filtering it as + // dead would leave the draw path never writing it (every distance then selects 1.0 + // and nothing clips, with no error anywhere). + if (isGlobalUboMember(uniform) && + uniform.name == MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME) { + artifacts.usesReservedClipDistanceEnables = true; + MGLOG_D("ProgramObject %u: Reflection - reserved clip-distance enables '%s' hidden from the GL " + "uniform surface", + in.externalIndex, uniform.name.c_str()); + continue; + } if (isGlobalUboMember(uniform) && uniform.stages == 0) { MGLOG_D("ProgramObject %u: Reflection - dead default-block uniform '%s' filtered from the GL " "surface", diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h index ad443b10..827aad73 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -131,6 +131,10 @@ namespace MobileGL::MG_State::GLState { // this bit. No new L1 key material: the key already covers // `requestedXfbVaryings`, of which this is a function. Bool captureRequestsPointSize = false; + // The same derived bit for gl_ClipDistance (any element): the clip-distance + // emulation declines such a program, since the capture wants the unmasked value at a + // varying the emulation never creates. Also a function of `requestedXfbVaryings`. + Bool captureRequestsClipDistance = false; // L1 shader-translation memo key for this program's SPIR-V (see // MG_Util/ShaderTranspiler/TranslationCache.h). Built HERE, at the tail of phase diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 58e99035..edf53899 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -799,6 +799,27 @@ namespace MobileGL::MG_State::GLState { MarkUBOContentDirtyRange(offset, static_cast(sizeof(Int))); return true; } + // ---- the reserved clip-distance enables (ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME) ---- + // + // Same contract as the gl_NumSamples pair above: phase-A gate, value-identical writes + // dropped without a content-version bump, false when there is nowhere to put it. + Bool UsesReservedClipDistanceEnables() const { return Artifacts().usesReservedClipDistanceEnables; } + Bool WriteReservedClipDistanceEnables(Uint32 enables) { + if (!UsesReservedClipDistanceEnables()) return false; + SpirvArtifacts& spirv = Spirv(); + const Uint offset = spirv.reservedClipDistanceEnablesOffset; + if (offset == kInvalidUniformOffset) return false; + if (static_cast(offset) + sizeof(Uint32) > spirv.globalUboScratch.size()) return false; + + Uint8* const slot = spirv.globalUboScratch.data() + offset; + Uint32 current = 0; + Memcpy(¤t, slot, sizeof(Uint32)); + if (current == enables) return true; + Memcpy(slot, &enables, sizeof(Uint32)); + MarkUBOContentDirtyRange(offset, static_cast(sizeof(Uint32))); + return true; + } + Bool ClipDistanceEmulated() const { return Spirv().clipDistanceEmulated; } // ---- glUniform* inside the phase-A -> phase-B window ---- // // True while the program is fully linked and fully queryable but its uniform shadow's diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp index 2dd344c2..188cd916 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -33,6 +33,12 @@ namespace MobileGL::MG_State::GLState { } std::atomic_flag g_pointSizeDeclineReported; std::atomic_flag g_pointSizeOptimizerFailureReported; + std::atomic_flag g_clipDistanceDeclineReported; + std::atomic_flag g_clipDistanceOptimizerFailureReported; + std::atomic_flag g_clipDistanceEmulationReported; + // Varying locations the clip carriers may reach. 32 vec4s is what both backends' target + // drivers expose (128 output components); a program past it keeps the decline. + constexpr Uint32 kClipDistanceCarrierLocationBudget = 32; } // namespace void ProgramSpirvTask::DeferLog(String line, const Int level) { @@ -154,8 +160,10 @@ namespace MobileGL::MG_State::GLState { m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesTessellationPointSize(); const Bool demoteGeometryPointSize = m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesGeometryPointSize(); + // Part of the frontend fingerprint (CompileEnv), so the L1 key already separates the two. + const Bool emulateClipDistance = m_phaseA->in.env != nullptr && m_phaseA->in.env->EmulatesClipDistance(); GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation, - nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize); + nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize, emulateClipDistance); // GlslangToSpv was the only consumer of the parsed ASTs; everything after this point // works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop // them here rather than at the end of the body, which is ~87% of this node's runtime @@ -214,7 +222,8 @@ namespace MobileGL::MG_State::GLState { void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex, const Bool deferOutputValidationForDirectVulkan, const Bool enableSpirvValidation, const Bool nativeFloat64, - const Bool demoteTessellationPointSize, const Bool demoteGeometryPointSize) { + const Bool demoteTessellationPointSize, const Bool demoteGeometryPointSize, + const Bool emulateClipDistance) { /* As we passed first stage compilation/linking, * we'll assume all the operations here should * pass. We may be able to employ some optimizations @@ -269,6 +278,42 @@ namespace MobileGL::MG_State::GLState { } artifacts.nativeFloat64 = keepFloat64; + // The clip-distance emulation, program-wide and BEFORE the sanitize chain, on glslang's + // raw output. It cannot run after it: a stage whose only default-block uniform is the + // reserved enables member (nothing in the application reads it - only this pass does) + // has its whole MGL_GLOBAL_UBO deleted by ADCE, and the producer would then fall back + // to clipping by every distance. Running first adds the read that keeps it alive. The + // point-size demotion below places its carrier past these carriers. + artifacts.clipDistanceEmulated = false; + if (emulateClipDistance) { + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + if (!ShaderCompiler::EmulateClipDistanceForProgram( + artifacts.generatedSpirv, handoff.shaderTypes, handoff.captureRequestsClipDistance, + kClipDistanceCarrierLocationBudget, outcome, !deferOutputValidationForDirectVulkan, + enableSpirvValidation)) { + DeferLog(std::format("ProgramObject {}: clip-distance emulation failed in the optimizer; the " + "program keeps gl_ClipDistance, which this backend cannot host", + externalIndex), + FirstTimeWarnLevel(g_clipDistanceOptimizerFailureReported)); + } else if (outcome.emulated) { + artifacts.clipDistanceEmulated = true; + // THE ARMING SIGNAL, INFO and latched: the integration lane that pins + // MOBILEGL_CLIP_DISTANCE_EMULATION=1 asserts on this line, because a healthy + // driver renders the same pixels whether the emulation ran or not. + const Bool first = !g_clipDistanceEmulationReported.test_and_set(std::memory_order_relaxed); + DeferLog(std::format("ProgramObject {}: gl_ClipDistance emulated ({} distance(s) carried to the " + "fragment stage as varyings and clipped by discard{})", + externalIndex, outcome.distanceCount, + outcome.enablesFound ? "" : "; no enables uniform, every distance clips"), + first ? MOBILEGL_LOG_LEVEL_INFO : MOBILEGL_LOG_LEVEL_DEBUG); + } else if (!outcome.declineDetail.empty()) { + DeferLog(std::format("ProgramObject {}: clip-distance emulation declined ({}); the program keeps " + "gl_ClipDistance, which this backend cannot host", + externalIndex, outcome.declineDetail), + FirstTimeWarnLevel(g_clipDistanceDeclineReported)); + } + } + // Linked SPIR-V generated, sanitize and optimize it Bool allOptimized = true; { @@ -348,6 +393,7 @@ namespace MobileGL::MG_State::GLState { artifacts.uniformOffsets.clear(); artifacts.globalUboScratch.clear(); artifacts.reservedNumSamplesOffset = ProgramObject::kInvalidUniformOffset; + artifacts.reservedClipDistanceEnablesOffset = ProgramObject::kInvalidUniformOffset; // kInvalidUniformOffset marks locations that end up without global-UBO backing // (e.g. the optimizer eliminated every use of the uniform); the fallback pass // below gives those locations tail storage so glUniform* always has a target. @@ -386,6 +432,10 @@ namespace MobileGL::MG_State::GLState { // precisely so no application can address it - so the lookup below would find // nothing and log it as unbacked. Only the fragment stage declares it, and // every stage's copy sits at the same offset in the one shared global UBO. + if (name == CLIP_DISTANCE_ENABLES_UNIFORM_NAME) { + artifacts.reservedClipDistanceEnablesOffset = offset; + continue; + } if (name == NUM_SAMPLES_UNIFORM_NAME) { artifacts.reservedNumSamplesOffset = offset; MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - reserved gl_NumSamples stand-in '%s' " diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h index c14ea1e4..8f041f6b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h @@ -67,7 +67,8 @@ namespace MobileGL::MG_State::GLState { void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex, Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation, Bool nativeFloat64, - Bool demoteTessellationPointSize, Bool demoteGeometryPointSize); + Bool demoteTessellationPointSize, Bool demoteGeometryPointSize, + Bool emulateClipDistance); void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex); // Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp index e70b8738..83ee53c8 100644 --- a/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp @@ -531,9 +531,9 @@ TEST(ProgramArtifactsCodec, TheTablesVisitEveryMemberExceptTheLiveProgram) { // P7 OQ-8: 59 members, 58 visited. The 59th is still the live TProgram; the 58th is // storageBlocks, which follows the record arm's guard (ProgramArtifacts.h). esOrigin then // made it 60 and 59. - EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 59u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 60u); EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 3u); - EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 8u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 10u); EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 14u); EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 11u); EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 20u); diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp index d3076696..6e035952 100644 --- a/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramArtifactsTest.cpp @@ -84,8 +84,8 @@ namespace { // P7 OQ-8 added LinkArtifacts::storageBlocks and its own three-field table; since P13 W5 // the member follows the record arm (ProgramArtifacts.h), which every library builds. EXPECT_EQ(CountFields(), 3u); - EXPECT_EQ(CountFields(), 59u); - EXPECT_EQ(CountFields(), 8u); + EXPECT_EQ(CountFields(), 60u); + EXPECT_EQ(CountFields(), 10u); } // A const walk hands the visitor const references, a non-const walk mutable ones: the one diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index c73228bb..5b6aff7f 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable( WidenImageFormatsTest.cpp GlslangCaptureTest.cpp DemotePointSizeTest.cpp + EmulateClipDistanceTest.cpp FragCoordYFlipTest.cpp PixelCenterIntegerTest.cpp ZeroLayerOutputTest.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/EmulateClipDistanceTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/EmulateClipDistanceTest.cpp new file mode 100644 index 00000000..a507a526 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/EmulateClipDistanceTest.cpp @@ -0,0 +1,368 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/EmulateClipDistanceTest.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 + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include +#include + +#include "spirv-tools/libspirv.hpp" + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::CompileEnv; +using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; +using MobileGL::MG_Util::ShaderTranspiler::SpvcSession; + +namespace { + // The exact bytes ProgramSpirvTask hands the emulation: linked, generated, sanitized. + Vector> CompileProgramToSpirv(const Vector>& stages, + Vector& types) { + using namespace MG_Util::ShaderTranspiler; + Vector> shaders; + types.clear(); + for (const auto& [stage, source] : stages) { + ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + shaders.push_back(shaderResult.value()); + types.push_back(stage); + } + ProgramAttrib programAttrib{.shaders = shaders}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + ProgramBinaryAttrib binaryAttrib{.shaderTypes = types, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult) return {}; + // RAW glslang output: ProgramSpirvTask runs the emulation BEFORE the sanitize chain. + return Move(binaryResult.value()); + } + + // What ProgramSpirvTask does right after the emulation, so every validity / transpile + // assertion is made against the bytes the backends really receive. + void SanitizeAll(Vector>& modules) { + for (auto& module : modules) { + EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true)); + } + } + + String Disassemble(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + EXPECT_TRUE(tools.Disassemble(spirv, &text)); + return text; + } + + String Transpile(const Vector& spirv) { + SpvcSession session(spirv, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log); + return essl ? essl.value() : String{}; + } + + Bool Validates(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String messages; + tools.SetMessageConsumer([&](spv_message_level_t, const char*, const spv_position_t&, const char* message) { + messages += message; + messages += '\n'; + }); + const Bool ok = tools.Validate(spirv); + EXPECT_TRUE(ok) << messages; + return ok; + } + + Bool Contains(const String& haystack, const char* needle) { return haystack.find(needle) != String::npos; } + + // The shape from the field report (Mali-G615, Espryt): a #version 330 core vertex stage + // writing all eight distances from uniform planes. The enables uniform is what + // InjectClipDistanceEnablesShim adds on an emulating backend; spelled out here because a + // unit test has no backend to arm the shim. + const char* kEightPlaneVertex = R"(#version 330 core +uniform uint mg_ClipDistanceEnables; +uniform vec4 u_ClipPlane[8]; +uniform mat4 u_ModelView; +in vec4 a_Position; +void main() { + vec4 eyePos = u_ModelView * a_Position; + gl_Position = eyePos; + gl_ClipDistance[0] = dot(u_ClipPlane[0], eyePos); + gl_ClipDistance[1] = dot(u_ClipPlane[1], eyePos); + gl_ClipDistance[2] = dot(u_ClipPlane[2], eyePos); + gl_ClipDistance[3] = dot(u_ClipPlane[3], eyePos); + gl_ClipDistance[4] = dot(u_ClipPlane[4], eyePos); + gl_ClipDistance[5] = dot(u_ClipPlane[5], eyePos); + gl_ClipDistance[6] = dot(u_ClipPlane[6], eyePos); + gl_ClipDistance[7] = dot(u_ClipPlane[7], eyePos); +} +)"; + + const char* kPlainFragment = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + constexpr Uint32 kBudget = 32; +} // namespace + +TEST(EmulateClipDistance, TheReportedEightPlaneVertexStageClipsByDiscard) { + Vector types; + auto modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kEightPlaneVertex}, {GL_FRAGMENT_SHADER, kPlainFragment}}, + types); + ASSERT_EQ(modules.size(), 2u); + ASSERT_TRUE(Contains(Disassemble(modules[0]), "OpCapability ClipDistance")); + + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + SanitizeAll(modules); + EXPECT_TRUE(outcome.emulated) << outcome.declineDetail; + EXPECT_EQ(outcome.distanceCount, 8u); + EXPECT_TRUE(outcome.enablesFound); + + for (const auto& module : modules) { + EXPECT_TRUE(Validates(module)); + EXPECT_FALSE(Contains(Disassemble(module), "OpCapability ClipDistance")); + } + const String vertexAsm = Disassemble(modules[0]); + EXPECT_TRUE(Contains(vertexAsm, "mg_ClipDistanceEmu0")); + EXPECT_TRUE(Contains(vertexAsm, "mg_ClipDistanceEmu1")); + EXPECT_TRUE(Contains(vertexAsm, "OpBitwiseAnd")); + const String fragmentAsm = Disassemble(modules[1]); + EXPECT_TRUE(Contains(fragmentAsm, "OpKill")); + EXPECT_TRUE(Contains(fragmentAsm, "mg_ClipDistanceEmu1")); + + // The ES hop must no longer need GL_EXT_clip_cull_distance anywhere. + const String vertexEssl = Transpile(modules[0]); + const String fragmentEssl = Transpile(modules[1]); + EXPECT_FALSE(Contains(vertexEssl, "gl_ClipDistance")) << vertexEssl; + EXPECT_FALSE(Contains(vertexEssl, "clip_cull_distance")) << vertexEssl; + EXPECT_TRUE(Contains(vertexEssl, "mg_ClipDistanceEmu0")) << vertexEssl; + EXPECT_TRUE(Contains(fragmentEssl, "discard")) << fragmentEssl; + EXPECT_FALSE(Contains(fragmentEssl, "clip_cull_distance")) << fragmentEssl; +} + +TEST(EmulateClipDistance, OneDistanceUsesOneCarrierAndNoEnablesMeansAllEnabled) { + const char* vertex = R"(#version 330 core +void main() { + gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0); + gl_ClipDistance[0] = gl_Position.x; +} +)"; + Vector types; + auto modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vertex}, {GL_FRAGMENT_SHADER, kPlainFragment}}, types); + ASSERT_EQ(modules.size(), 2u); + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + SanitizeAll(modules); + EXPECT_TRUE(outcome.emulated) << outcome.declineDetail; + EXPECT_EQ(outcome.distanceCount, 1u); + EXPECT_FALSE(outcome.enablesFound); + for (const auto& module : modules) + EXPECT_TRUE(Validates(module)); + const String vertexAsm = Disassemble(modules[0]); + EXPECT_TRUE(Contains(vertexAsm, "mg_ClipDistanceEmu0")); + EXPECT_FALSE(Contains(vertexAsm, "mg_ClipDistanceEmu1")); +} + +TEST(EmulateClipDistance, AProgramThatNeverClipsIsUntouched) { + const char* vertex = R"(#version 330 core +void main() { gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0); } +)"; + Vector types; + auto modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vertex}, {GL_FRAGMENT_SHADER, kPlainFragment}}, types); + ASSERT_EQ(modules.size(), 2u); + const auto before = modules; + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + EXPECT_FALSE(outcome.emulated); + EXPECT_TRUE(outcome.declineDetail.empty()); + EXPECT_EQ(modules, before); +} + +TEST(EmulateClipDistance, AGeometryStageProducesBeforeEveryEmitAndTheVertexWriteIsRedirected) { + const char* vertex = R"(#version 330 core +uniform uint mg_ClipDistanceEnables; +void main() { + gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0); + gl_ClipDistance[0] = -1.0; +} +)"; + const char* geometry = R"(#version 330 core +uniform uint mg_ClipDistanceEnables; +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +void main() { + for (int i = 0; i < 3; ++i) { + gl_Position = gl_in[i].gl_Position; + gl_ClipDistance[0] = gl_in[i].gl_Position.x; + gl_ClipDistance[1] = 1.0; + EmitVertex(); + } + EndPrimitive(); +} +)"; + Vector types; + auto modules = CompileProgramToSpirv( + {{GL_VERTEX_SHADER, vertex}, {GL_GEOMETRY_SHADER, geometry}, {GL_FRAGMENT_SHADER, kPlainFragment}}, types); + ASSERT_EQ(modules.size(), 3u); + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + SanitizeAll(modules); + EXPECT_TRUE(outcome.emulated) << outcome.declineDetail; + EXPECT_EQ(outcome.distanceCount, 2u); + for (const auto& module : modules) { + EXPECT_TRUE(Validates(module)); + EXPECT_FALSE(Contains(Disassemble(module), "OpCapability ClipDistance")); + } + // The vertex stage is not the producer: redirected, no carriers. + EXPECT_FALSE(Contains(Disassemble(modules[0]), "mg_ClipDistanceEmu0")); + EXPECT_TRUE(Contains(Disassemble(modules[1]), "mg_ClipDistanceEmu0")); +} + +TEST(EmulateClipDistance, AFragmentStageReadOfGlClipDistanceIsServedFromTheCarriers) { + const char* vertex = R"(#version 330 core +void main() { + gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0); + gl_ClipDistance[0] = 0.5; +} +)"; + const char* fragment = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(gl_ClipDistance[0], 1.0, 0.0, 1.0); } +)"; + Vector types; + auto modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, vertex}, {GL_FRAGMENT_SHADER, fragment}}, types); + ASSERT_EQ(modules.size(), 2u); + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + SanitizeAll(modules); + EXPECT_TRUE(outcome.emulated) << outcome.declineDetail; + for (const auto& module : modules) { + EXPECT_TRUE(Validates(module)); + EXPECT_FALSE(Contains(Disassemble(module), "OpCapability ClipDistance")); + } + const String fragmentEssl = Transpile(modules[1]); + EXPECT_FALSE(Contains(fragmentEssl, "gl_ClipDistance")) << fragmentEssl; + EXPECT_TRUE(Contains(fragmentEssl, "mg_ClipDistanceShadow")) << fragmentEssl; +} + +TEST(EmulateClipDistance, AGlInReadDeclinesAndLeavesEveryModuleAlone) { + const char* vertex = R"(#version 330 core +void main() { + gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0); + gl_ClipDistance[0] = 1.0; +} +)"; + const char* geometry = R"(#version 330 core +layout(points) in; +layout(points, max_vertices = 1) out; +void main() { + gl_Position = gl_in[0].gl_Position + vec4(gl_in[0].gl_ClipDistance[0]); + EmitVertex(); +} +)"; + Vector types; + auto modules = CompileProgramToSpirv( + {{GL_VERTEX_SHADER, vertex}, {GL_GEOMETRY_SHADER, geometry}, {GL_FRAGMENT_SHADER, kPlainFragment}}, types); + ASSERT_EQ(modules.size(), 3u); + const auto before = modules; + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + EXPECT_FALSE(outcome.emulated); + EXPECT_NE(outcome.declineDetail.find("gl_in"), String::npos) << outcome.declineDetail; + EXPECT_EQ(modules, before); +} + +TEST(EmulateClipDistance, ACaptureOfGlClipDistanceDeclines) { + Vector types; + auto modules = CompileProgramToSpirv({{GL_VERTEX_SHADER, kEightPlaneVertex}, {GL_FRAGMENT_SHADER, kPlainFragment}}, + types); + ASSERT_EQ(modules.size(), 2u); + const auto before = modules; + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, true, kBudget, outcome, true, true)); + EXPECT_FALSE(outcome.emulated); + EXPECT_FALSE(outcome.declineDetail.empty()); + EXPECT_EQ(modules, before); +} + +// End to end through the source pipeline: the shim's uniform must survive glslang and the +// sanitize chain so the producer finds it. Without this the draw path's mask has nowhere to +// land and every distance silently clips (the all-enabled fallback). +TEST(EmulateClipDistance, TheShimmedEnablesUniformReachesTheProducer) { + CompileEnv emulating{}; + emulating.backend = BackendType::DirectGLES; + emulating.params.EmulatesClipDistance = true; + String vertex = 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; +} +)"; + String fragment = "#version 400 core\nout vec4 o;\nvoid main() { o = vec4(1.0); }\n"; + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Vertex, vertex, emulating); + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Fragment, fragment, emulating); + ASSERT_TRUE(Contains(vertex, MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) << vertex; + Vector types; + auto modules = + CompileProgramToSpirv({{GL_VERTEX_SHADER, vertex.c_str()}, {GL_FRAGMENT_SHADER, fragment.c_str()}}, types); + ASSERT_EQ(modules.size(), 2u); + EXPECT_TRUE(Contains(Disassemble(modules[0]), "mg_ClipDistanceEnables")) << Disassemble(modules[0]); + ShaderCompiler::ClipDistanceEmulationOutcome outcome; + ASSERT_TRUE(ShaderCompiler::EmulateClipDistanceForProgram(modules, types, false, kBudget, outcome, true, true)); + EXPECT_TRUE(outcome.emulated) << outcome.declineDetail; + EXPECT_TRUE(outcome.enablesFound) << vertex; + // And the read the producer added keeps the member alive through the sanitize chain. + SanitizeAll(modules); + for (const auto& module : modules) + EXPECT_TRUE(Validates(module)); + EXPECT_TRUE(Contains(Disassemble(modules[0]), "mg_ClipDistanceEnables")); +} + +TEST(EmulateClipDistance, TheEnablesShimArmsOnlyOnAnEmulatingBackendAndOnlyWhereItIsNamed) { + CompileEnv emulating{}; + emulating.backend = BackendType::DirectGLES; + emulating.params.EmulatesClipDistance = true; + CompileEnv native = emulating; + native.params.EmulatesClipDistance = false; + + const String vertex = "#version 330 core\nvoid main() { gl_Position = vec4(0.0); gl_ClipDistance[0] = 1.0; }\n"; + const String fragment = "#version 330 core\nout vec4 o; void main() { o = vec4(1.0); }\n"; + const String nonClipping = "#version 330 core\nvoid main() { gl_Position = vec4(0.0); }\n"; + + String source = vertex; + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Vertex, source, emulating); + EXPECT_TRUE(Contains(source, MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) << source; + + source = vertex; + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Vertex, source, native); + EXPECT_FALSE(Contains(source, MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) << source; + + source = fragment; + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Fragment, source, emulating); + EXPECT_FALSE(Contains(source, MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) << source; + + source = nonClipping; + MG_Util::ShaderTranspiler::PreprocessShaderSource(ShaderStage::Vertex, source, emulating); + EXPECT_FALSE(Contains(source, MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) << source; +} diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 44dbddd4..484dcd3b 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -1997,6 +1997,39 @@ namespace MobileGL::MG_Util::BackendLoader { MGLOG_I(" GL_MAX_COMPUTE_IMAGE_UNIFORMS: %d", caps.MaxComputeImageUniforms); MGLOG_I(" GL_MAX_DRAW_BUFFERS: %d", caps.MaxDrawBuffers); MGLOG_I(" GL_MAX_COLOR_ATTACHMENTS: %d", caps.MaxColorAttachments); + // Native clip distances or MobileGL's emulation. Native only where the driver both + // advertises GL_EXT_clip_cull_distance AND passes the probe (its own control first): + // an extension that is listed but does not clip, or clips by disabled distances, is a + // wrong answer the application sees, and the emulation answers it correctly. The probe + // is skipped entirely without the extension - there is nothing to measure. + if (caps.SupportsClipDistance && caps.MaxClipDistances > 0) { + const SelfTest::NativeClipDistanceMeasurement& clip = SelfTest::NativeClipDistance(glesFuncs); + caps.NativeClipDistanceClips = clip.ran && clip.clips; + caps.NativeClipDistanceHonorsEnables = clip.ran && clip.honorsEnables; + MGLOG_I(" Native gl_ClipDistance probe: %s", + !clip.ran ? ("inconclusive (" + clip.failure + ")").c_str() + : !clip.clips ? "an enabled negative distance did NOT clip" + : !clip.honorsEnables ? "clips, but by DISABLED distances too" + : "clips, and honors the per-distance enables"); + } + caps.EmulatesClipDistance = !(caps.SupportsClipDistance && caps.MaxClipDistances > 0 && + caps.NativeClipDistanceClips && caps.NativeClipDistanceHonorsEnables); + switch (MG_Config::Features.ClipDistanceEmulation) { + case MG_Config::QuirkOverride::ForceOn: + caps.EmulatesClipDistance = true; + MGLOG_I(" gl_ClipDistance: emulation forced ON by MOBILEGL_CLIP_DISTANCE_EMULATION"); + break; + case MG_Config::QuirkOverride::ForceOff: + caps.EmulatesClipDistance = false; + MGLOG_I(" gl_ClipDistance: emulation forced OFF by MOBILEGL_CLIP_DISTANCE_EMULATION"); + break; + case MG_Config::QuirkOverride::Auto: + default: + break; + } + MGLOG_I(" gl_ClipDistance: %s", caps.EmulatesClipDistance + ? "EMULATED (varyings + fragment discard; 8 distances advertised)" + : "native (GL_EXT_clip_cull_distance)"); // Worth spelling the reason out for the same reason the per-stage storage block counts // are: a zero here is what stops an application's gl_ClipDistance from ever clipping, and // reading it back from an artifact is the difference between "MobileGL dropped my draw" diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index b271a7bd..a8b277fe 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1274,6 +1274,19 @@ namespace MobileGL { // 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; + // What SelfTest::ProbeNativeClipDistance measured - only run where the extension is + // advertised: an enabled negative distance removes its fragments, and a disabled one + // removes nothing. + Bool NativeClipDistanceClips = false; + Bool NativeClipDistanceHonorsEnables = false; + // DirectGLES lowers gl_ClipDistance to varyings plus a fragment discard + // (ShaderCompiler::EmulateClipDistanceForProgram) instead of handing it to the + // driver: no extension, no distances behind it, or a probe that caught the driver + // not clipping or not honoring the enables. MOBILEGL_CLIP_DISTANCE_EMULATION + // overrides. While set, the CLIP_DISTANCEi enables are NOT forwarded to the driver - + // no emulated module writes the built-in, and an enabled distance nobody writes is + // undefined clipping. + Bool EmulatesClipDistance = false; // GL_OES_viewport_array is present: the driver knows gl_ViewportIndex in ESSL - and // only then. ESSL has no core spelling for it at ANY version, while SPIRV-Cross prints // the identifier bare and requests nothing for it (contrast gl_Layer, which it backs diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp index bc91f7d3..925d0875 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp @@ -2066,6 +2066,165 @@ namespace MobileGL::MG_Util::SelfTest { return measurement; } + namespace { + // ===================== NATIVE CLIP DISTANCE ===================== + + constexpr const char* kClipDistanceProbeName = "clip distance"; + // Four pixels across: pixel 0 is centred at NDC x = -0.75, pixel 3 at +0.75, so the + // distance gl_ClipDistance[0] = x is clearly negative on one and positive on the other. + constexpr GLsizei kClipDistanceProbeWidth = 4; + // GL_CLIP_DISTANCE0_EXT / 1_EXT: the desktop values, absent from the ES headers. + constexpr GLenum kClipDistance0 = 0x3000; + constexpr GLenum kClipDistance1 = 0x3001; + + const char* const kClipDistanceProbeVertexSource = + "#version 300 es\n" + "#extension GL_EXT_clip_cull_distance : require\n" + "precision highp float;\n" + "void main() {\n" + " vec2 mg_p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, (gl_VertexID == 2) ? 3.0 : -1.0);\n" + " gl_Position = vec4(mg_p, 0.0, 1.0);\n" + " gl_ClipDistance[0] = mg_p.x;\n" + " gl_ClipDistance[1] = 1.0;\n" + "}\n"; + // The control: the same stage with a distance that is positive everywhere. + const char* const kClipDistanceProbeControlVertexSource = + "#version 300 es\n" + "#extension GL_EXT_clip_cull_distance : require\n" + "precision highp float;\n" + "void main() {\n" + " vec2 mg_p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, (gl_VertexID == 2) ? 3.0 : -1.0);\n" + " gl_Position = vec4(mg_p, 0.0, 1.0);\n" + " gl_ClipDistance[0] = 1.0;\n" + " gl_ClipDistance[1] = 1.0;\n" + "}\n"; + const char* const kClipDistanceProbeFragmentSource = "#version 300 es\n" + "precision mediump float;\n" + "layout(location = 0) out vec4 mg_probeColor;\n" + "void main() { mg_probeColor = vec4(0.0, 1.0, 0.0, 1.0); }\n"; + + Bool IsProbeGreen(const GLubyte* pixel) { return pixel[0] < 64 && pixel[1] > 192; } + + // One draw into the bound 4x1 target, cleared red first. Returns false when the draw or + // the read-back raised an error (inconclusive). + Bool DrawClipDistanceProbe(const GLESFunctionsTable& gl, Bool enableDistance0, Bool& leftPainted, + Bool& rightPainted) { + if (enableDistance0) { + gl.glEnable(kClipDistance0); + } else { + gl.glDisable(kClipDistance0); + } + gl.glDisable(kClipDistance1); + gl.glClearColor(1.0f, 0.0f, 0.0f, 1.0f); + gl.glClear(GL_COLOR_BUFFER_BIT); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLES, 0, 3); + if (gl.glGetError() != GL_NO_ERROR) return false; + GLubyte row[kClipDistanceProbeWidth * 4] = {}; + gl.glReadPixels(0, 0, kClipDistanceProbeWidth, 1, GL_RGBA, GL_UNSIGNED_BYTE, row); + if (gl.glGetError() != GL_NO_ERROR) return false; + leftPainted = IsProbeGreen(row); + rightPainted = IsProbeGreen(row + (kClipDistanceProbeWidth - 1) * 4); + return true; + } + } // namespace + + NativeClipDistanceMeasurement ProbeNativeClipDistance(const GLESFunctionsTable& gl) { + NativeClipDistanceMeasurement measurement; + if (!HasIoBlockProbeEntryPoints(gl)) { + measurement.failure = "a probe entry point is missing"; + return measurement; + } + + SavedState saved; + Save(gl, saved); + GLboolean savedColorMask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE}; + const Bool canMaskColor = gl.glColorMask != nullptr && gl.glGetBooleanv != nullptr; + if (canMaskColor) { + gl.glGetBooleanv(GL_COLOR_WRITEMASK, savedColorMask); + gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + } + GLuint vao = 0; + gl.glGenVertexArrays(1, &vao); + gl.glBindVertexArray(vao); + PrepareForProbeDraw(gl); + + Vector stages; + stages.push_back({GL_VERTEX_SHADER, kClipDistanceProbeVertexSource, "vertex"}); + stages.push_back({GL_FRAGMENT_SHADER, kClipDistanceProbeFragmentSource, "fragment"}); + const ProgramBuild build = BuildProgram(gl, stages, kClipDistanceProbeName); + Vector controlStages; + controlStages.push_back({GL_VERTEX_SHADER, kClipDistanceProbeControlVertexSource, "control vertex"}); + controlStages.push_back({GL_FRAGMENT_SHADER, kClipDistanceProbeFragmentSource, "fragment"}); + const ProgramBuild control = BuildProgram(gl, controlStages, kClipDistanceProbeName); + if (!build.compiled || !control.compiled) { + measurement.failure = "the probe's gl_ClipDistance vertex stage did not compile"; + } else if (!build.linked || !control.linked) { + measurement.failure = + "the probe program did not link: " + FirstLine(!build.linked ? build.infoLog : control.infoLog); + } else { + GLuint renderbuffer = 0; + GLuint framebuffer = 0; + gl.glGenRenderbuffers(1, &renderbuffer); + gl.glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + gl.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kClipDistanceProbeWidth, 1); + gl.glGenFramebuffers(1, &framebuffer); + gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + gl.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { + measurement.failure = "the probe framebuffer is incomplete"; + } else { + gl.glViewport(0, 0, kClipDistanceProbeWidth, 1); + Bool left = false; + Bool right = false; + // THE CONTROL first: a positive distance, enabled, so both halves must paint. + gl.glUseProgram(control.program); + const Bool controlRan = DrawClipDistanceProbe(gl, true, left, right); + gl.glUseProgram(build.program); + if (!controlRan || !left || !right) { + measurement.failure = "the control draw (a positive distance, enabled) did not paint the target"; + } else if (!DrawClipDistanceProbe(gl, true, left, right) || !right) { + measurement.failure = "the clipped draw raised an error or lost its kept half"; + } else { + measurement.clips = !left; + Bool leftAfter = false; + Bool rightAfter = false; + if (DrawClipDistanceProbe(gl, false, leftAfter, rightAfter) && rightAfter) { + measurement.ran = true; + measurement.honorsEnables = leftAfter; + } else { + measurement.failure = "the re-disabled draw raised an error or lost its kept half"; + } + } + } + gl.glDisable(kClipDistance0); + gl.glDisable(kClipDistance1); + if (framebuffer != 0) gl.glDeleteFramebuffers(1, &framebuffer); + if (renderbuffer != 0) gl.glDeleteRenderbuffers(1, &renderbuffer); + } + if (build.program != 0) gl.glDeleteProgram(build.program); + if (control.program != 0) gl.glDeleteProgram(control.program); + + if (vao != 0) { + gl.glBindVertexArray(0); + gl.glDeleteVertexArrays(1, &vao); + } + if (canMaskColor) { + gl.glColorMask(savedColorMask[0], savedColorMask[1], savedColorMask[2], savedColorMask[3]); + } + Restore(gl, saved); + Drain(gl); + if (!measurement.failure.empty()) { + MGLOG_I("[driver-bug] %s probe: %s", kClipDistanceProbeName, measurement.failure.c_str()); + } + return measurement; + } + + const NativeClipDistanceMeasurement& NativeClipDistance(const GLESFunctionsTable& gl) { + static const NativeClipDistanceMeasurement measurement = ProbeNativeClipDistance(gl); + return measurement; + } + namespace { // ===================== PACKED16 COPY-IMAGE FIELD ORDER ===================== diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h index 9d64a705..517fa080 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h @@ -95,6 +95,38 @@ namespace MobileGL::MG_Util::SelfTest { // ProbeLocatedIoBlocksLosePayload(), evaluated at most once per process. const LocatedIoBlockMeasurement& LocatedIoBlocksLosePayload(const MG_External::GLESFunctionsTable& gl); + // What the native clip-distance probe measured. Not a driver-bug finding by itself: the + // loader turns it into the DirectGLES choice between the driver's gl_ClipDistance and + // MobileGL's emulation (EmulateClipDistancePass), and the POST "Clip distances" row reports + // that choice. + struct NativeClipDistanceMeasurement { + // The probe built its program and drew it; every field below is meaningless otherwise. + Bool ran = false; + // GL_CLIP_DISTANCE0_EXT enabled, distance negative on the left half: the left half was + // removed and the right half painted. + Bool clips = false; + // GL_CLIP_DISTANCE0_EXT disabled, same distances: nothing was removed. + Bool honorsEnables = false; + // The driver's first info-log line (or a short reason) when the probe could not run. + String failure; + }; + + // Draws full-viewport triangles under GL_EXT_clip_cull_distance. THE CONTROL first: a + // program whose gl_ClipDistance[0] is +1 everywhere, with distance 0 enabled - both halves + // must paint whatever the driver does with enables, or the extension's draw itself is broken + // and the probe has nothing to say. Then the SUBJECT, writing gl_ClipDistance[0] = x: with + // distance 0 enabled the left half must vanish, and with it disabled nothing may vanish + // (Mesa's llvmpipe ES driver clips by every written distance regardless of the enables, a + // wrong answer GL applications see). + // + // Requires the extension; the caller only runs it where the extension string names it. + // Restores every piece of GL state it touches and leaves both distances disabled - the + // state DirectGLES's enable forwarding assumes a fresh context is in. + NativeClipDistanceMeasurement ProbeNativeClipDistance(const MG_External::GLESFunctionsTable& gl); + + // ProbeNativeClipDistance(), evaluated at most once per process. + const NativeClipDistanceMeasurement& NativeClipDistance(const MG_External::GLESFunctionsTable& gl); + // Blits one layer of an RGBA8 2D array onto another array's layer 1 and reports whether the // copy landed where it was asked to. Returns true only when the destination layer is ignored // while the control lands correctly. diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index d0a4f74b..6547ed6b 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -340,6 +340,65 @@ namespace MobileGL::MG_Util::SelfTest { } } + // What the emulation still cannot do, shared by both backends' WARN rows. + constexpr const char* kClipDistanceEmulationResidue = + "MobileGL emulates 8 distances: gl_ClipDistance becomes two ordinary varyings and the " + "fragment stage discards where a distance is negative, honoring GL_CLIP_DISTANCEi. It costs " + "two varying locations and early depth/stencil for programs that clip; transform-feedback " + "capture of gl_ClipDistance and gl_in[].gl_ClipDistance reads are not emulated"; + + // GL_MAX_CLIP_DISTANCES >= 8 is a GL 3.0 requirement, so a backend that neither hosts nor + // emulates the built-in fails a capability every GL 3.x+ application may lean on. + void EvaluateGlesClipDistanceRows(ReportBuilder& builder, const MG_External::GLESCapabilities& caps, + const MG_External::GLESFunctionsTable& glesFuncs) { + // Only meaningful where the loader ran it (extension present); cached per process. + String probeFailure; + if (caps.SupportsClipDistance && caps.MaxClipDistances > 0) { + const NativeClipDistanceMeasurement& probe = NativeClipDistance(glesFuncs); + if (!probe.ran) probeFailure = probe.failure; + } + const Bool native = caps.SupportsClipDistance && caps.MaxClipDistances > 0 && + caps.NativeClipDistanceClips && caps.NativeClipDistanceHonorsEnables; + if (!caps.EmulatesClipDistance && native) { + builder.Pass("Clip distances", + format("native via GL_EXT_clip_cull_distance ({} distances); probe: an enabled distance " + "clips and a disabled one does not", + caps.MaxClipDistances)); + } else if (caps.EmulatesClipDistance) { + const String reason = + !caps.SupportsClipDistance ? String("no GL_EXT_clip_cull_distance") + : caps.MaxClipDistances <= 0 + ? String("GL_EXT_clip_cull_distance reports no distances") + : !probeFailure.empty() + ? "GL_EXT_clip_cull_distance is listed, but the native probe was inconclusive (" + + probeFailure + ")" + : !caps.NativeClipDistanceClips + ? String("GL_EXT_clip_cull_distance is listed, but in the probe an enabled negative " + "distance did not clip") + : !caps.NativeClipDistanceHonorsEnables + ? String("the driver clips by DISABLED distances too (probe)") + : String("forced by MOBILEGL_CLIP_DISTANCE_EMULATION=1"); + builder.Warn("Clip distances", format("{}; {}", reason, kClipDistanceEmulationResidue)); + } else { + builder.FailOptional("Clip distances", + caps.SupportsClipDistance + ? "GL_EXT_clip_cull_distance is listed but misbehaves in the probe, and the " + "emulation is disabled (MOBILEGL_CLIP_DISTANCE_EMULATION=0)" + : "no GL_EXT_clip_cull_distance and the emulation is disabled " + "(MOBILEGL_CLIP_DISTANCE_EMULATION=0): GL_MAX_CLIP_DISTANCES = 0, so a " + "gl_ClipDistance shader fails to compile"); + } + if (caps.SupportsClipDistance && caps.MaxCullDistances > 0) { + builder.Pass("Cull distances", + format("native via GL_EXT_clip_cull_distance ({} distances)", caps.MaxCullDistances)); + } else { + builder.FailOptional("Cull distances", + "no GL_EXT_clip_cull_distance: GL_MAX_CULL_DISTANCES = 0. Culling is per " + "primitive, which a per-fragment discard cannot reproduce, so a " + "gl_CullDistance shader fails to compile"); + } + } + void EvaluateGlesChecklist(ReportBuilder& builder, const MG_External::GLESCapabilities& caps, const MG_External::GLESFunctionsTable& glesFuncs) { const Int major = caps.GLESVersion.Major; @@ -388,6 +447,7 @@ namespace MobileGL::MG_Util::SelfTest { "hard-fails, and a second fragment output cannot be produced any other " "way"); } + EvaluateGlesClipDistanceRows(builder, caps, glesFuncs); if (es31) { GLint maxVertexSsboBlocks = 0; @@ -2776,6 +2836,38 @@ namespace MobileGL::MG_Util::SelfTest { builder.FailOptional("dualSrcBlend", "unsupported; a draw using a GL_SRC1_* blend factor hard-fails, and a " "second fragment output cannot be produced any other way"); } + // Mirrors BackendObject_DirectVulkan's decision: emulate exactly when the feature is + // absent, unless MOBILEGL_CLIP_DISTANCE_EMULATION says otherwise. + { + const Bool hasFeature = features.shaderClipDistance == VK_TRUE; + Bool emulate = !hasFeature; + if (MG_Config::Features.ClipDistanceEmulation == MG_Config::QuirkOverride::ForceOn) emulate = true; + if (MG_Config::Features.ClipDistanceEmulation == MG_Config::QuirkOverride::ForceOff) emulate = false; + if (!emulate && hasFeature) { + builder.Pass("shaderClipDistance", + format("native ({} distances); GL_CLIP_DISTANCEi enables are not applied on this backend " + "yet, so every distance a shader writes clips", + properties.limits.maxClipDistances)); + } else if (emulate) { + builder.Warn("shaderClipDistance", + format("{}; {}", + hasFeature ? "forced by MOBILEGL_CLIP_DISTANCE_EMULATION=1" + : "unsupported by the device", + kClipDistanceEmulationResidue)); + } else { + builder.FailOptional("shaderClipDistance", + "unsupported, and the emulation is disabled (MOBILEGL_CLIP_DISTANCE_EMULATION=0): " + "GL_MAX_CLIP_DISTANCES = 0, so a gl_ClipDistance shader fails to compile"); + } + if (features.shaderCullDistance == VK_TRUE) { + builder.Pass("shaderCullDistance", format("native ({} distances)", properties.limits.maxCullDistances)); + } else { + builder.FailOptional("shaderCullDistance", + "unsupported: GL_MAX_CULL_DISTANCES = 0. Culling is per primitive, which a " + "per-fragment discard cannot reproduce, so a gl_CullDistance shader fails to " + "compile"); + } + } // The Magma counterpart of the GLES "Buffer textures" row, so the two sections can be // read side by side. Vulkan has no optional-feature bit here: a uniform texel buffer is // core, and maxTexelBufferElements has a spec floor of 65536 - exactly the GL 3.1 floor diff --git a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp index fd2caaeb..d5bcc2c6 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.cpp @@ -75,6 +75,10 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // and expands gl_MaxClipDistances from the same number, so it decides both whether a // shader compiles at all and what a module that reads the constant generates. HashValue(state, env.params.MaxClipDistances); + // Unlike the point-size demotion bits this IS frontend material: when it is set the + // source pipeline declares the reserved enables uniform in every pre-rasterization + // stage that names gl_ClipDistance, so glslang parses a different source. + HashValue(state, env.params.EmulatesClipDistance); // The cull-distance pair, added when the GL 4.6 API-surface wave made them env-derived: // they were bare literals (8/8) in BuildTBuiltInResource while no backend had ever been // asked whether it can host a cull distance. Exactly the MaxClipDistances class - glslang diff --git a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h index 030240f5..73b9dbc7 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h +++ b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h @@ -165,6 +165,10 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // fingerprint: glslang produces the same thing either way. Bool DemotesTessellationPointSize() const { return HasBackend() && !params.SupportsTessellationPointSize; } Bool DemotesGeometryPointSize() const { return HasBackend() && !params.SupportsGeometryPointSize; } + // The backend cannot clip with gl_ClipDistance natively and emulates it + // (BackendCaps::EmulatesClipDistance). Part of the frontend fingerprint, not a separate + // L1 bit: it changes the parsed source (the reserved enables uniform), not only phase B. + Bool EmulatesClipDistance() const { return HasBackend() && params.EmulatesClipDistance; } // Matches the historical rule exactly: with no active backend every extension counts // as advertised, because the frontend then has nothing to gate against. Bool IsExtensionAdvertised(GLExtension extension) const { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 4ab67eba..d11088c4 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -61,6 +61,7 @@ #include "SpirvPasses/LegalizeResourceArrayIndexPass.h" #include "SpirvPasses/FlattenAtomicCounterBlockPass.h" #include "SpirvPasses/DemotePointSizePass.h" +#include "SpirvPasses/EmulateClipDistancePass.h" #include "spirv-tools/libspirv.h" #include "spirv-tools/optimizer.hpp" #include "source/opt/build_module.h" @@ -1215,6 +1216,127 @@ namespace MobileGL { return true; } + Bool ShaderCompiler::EmulateClipDistanceForProgram(Vector>& modules, + const Vector& shaderTypes, + const Bool captureRequestsClipDistance, + const Uint32 maxVaryingLocations, + ClipDistanceEmulationOutcome& outcome, + const bool validateOutput, + const bool enableSpirvValidation) { + outcome = {}; + // Pipeline order: VS, TCS, TES, GS, FS. + Int stageIndex[5] = {-1, -1, -1, -1, -1}; + for (SizeT i = 0; i < shaderTypes.size() && i < modules.size(); ++i) { + switch (shaderTypes[i]) { + case GL_VERTEX_SHADER: stageIndex[0] = static_cast(i); break; + case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast(i); break; + case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast(i); break; + case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast(i); break; + case GL_FRAGMENT_SHADER: stageIndex[4] = static_cast(i); break; + default: break; + } + } + + ClipDistanceModuleProbe probes[5]; + Bool anyAccess = false; + Uint32 carrierLocation = 0; + for (Int stage = 0; stage < 5; ++stage) { + if (stageIndex[stage] < 0) continue; + const Vector& module = modules[stageIndex[stage]]; + probes[stage] = EmulateClipDistancePass::ProbeModule(module); + if (!probes[stage].parsed) return true; // broken for other reasons; owns its failure + anyAccess |= probes[stage].declaresClipDistanceCapability; + const PointSizeModuleProbe locations = ProbePointSizeModule(module); + carrierLocation = std::max(carrierLocation, locations.locationSlotEnd); + } + if (!anyAccess) return true; + if (captureRequestsClipDistance) { + outcome.declineDetail = "transform feedback captures gl_ClipDistance"; + return true; + } + + // The producer: the last pre-rasterization stage present, and only when it + // actually writes the built-in - an earlier stage's writes are dead in GL unless + // a later stage reads gl_in[].gl_ClipDistance, which the pass declines. + Int producer = -1; + for (Int stage = 3; stage >= 0; --stage) { + if (stage == 1) continue; // a control stage is never last + if (stageIndex[stage] >= 0) { + producer = stage; + break; + } + } + Uint32 distanceCount = 0; + if (producer >= 0 && probes[producer].declaresClipDistanceCapability) { + distanceCount = probes[producer].outputDistanceCount; + } + if (distanceCount > EmulateClipDistancePass::kMaxCarriers * 4) { + outcome.declineDetail = std::format("{} clip distances, more than the eight the carriers hold", + distanceCount); + return true; + } + const Uint32 carrierCount = (distanceCount + 3) / 4; + if (carrierCount > 0 && carrierLocation + carrierCount > maxVaryingLocations) { + outcome.declineDetail = std::format( + "the program's varyings already reach location {}, leaving no room for {} clip carrier(s) " + "under the {}-location budget", + carrierLocation, carrierCount, maxVaryingLocations); + return true; + } + + Vector> rewritten(modules.size()); + Bool rewrote[5] = {false, false, false, false, false}; + Bool enablesFound = false; + for (Int stage = 0; stage < 5; ++stage) { + const Int moduleIndex = stageIndex[stage]; + if (moduleIndex < 0) continue; + EmulateClipDistanceOptions options; + options.location = carrierLocation; + if (stage == 4) { + options.role = EmulateClipDistanceOptions::Role::Consumer; + options.distanceCount = distanceCount; + if (distanceCount == 0 && !probes[stage].declaresClipDistanceCapability) continue; + } else if (stage == producer && distanceCount > 0) { + options.role = EmulateClipDistanceOptions::Role::Producer; + options.enablesMemberName = CLIP_DISTANCE_ENABLES_UNIFORM_NAME; + } else { + options.role = EmulateClipDistanceOptions::Role::Redirect; + if (!probes[stage].declaresClipDistanceCapability) continue; + } + + EmulateClipDistanceReport report; + spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(EmulateClipDistancePass::CreateEmulateClipDistancePass(options, &report)); + if (!RunOptimizerChecked("EmulateClipDistanceForProgram", optimizer, modules[moduleIndex], + rewritten[moduleIndex], validateOutput, enableSpirvValidation)) { + return false; // modules untouched: nothing was committed + } + if (report.declined) { + outcome.declineDetail = Move(report.declineReason); + return true; + } + if (options.role == EmulateClipDistanceOptions::Role::Producer) { + if (report.distanceCount != distanceCount) { + outcome.declineDetail = std::format( + "the producing stage carries {} clip distances where {} were planned", + report.distanceCount, distanceCount); + return true; + } + enablesFound = report.enablesFound; + } + rewrote[stage] = report.rewrote; + } + + for (Int stage = 0; stage < 5; ++stage) { + if (!rewrote[stage]) continue; + modules[stageIndex[stage]] = Move(rewritten[stageIndex[stage]]); + } + outcome.emulated = true; + outcome.distanceCount = distanceCount; + outcome.enablesFound = enablesFound; + return true; + } + Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector& spirv) { if (spirv.empty()) { // Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 3aed8fc3..0d445fdd 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -650,6 +650,39 @@ namespace MobileGL { Bool demoteGeometry, Bool captureRequestsPointSize, PointSizeDemotionOutcome& outcome, bool validateOutput = true, bool enableSpirvValidation = false); + // What the program-scoped clip-distance emulation left behind. `emulated` false + // with an empty detail means the program never touches gl_ClipDistance; false + // WITH a detail is a shape the pass cannot express (modules byte-identical, so the + // program keeps the built-in and the backend's own refusal of it). + struct ClipDistanceEmulationOutcome { + Bool emulated = false; + // How many distances the producing stage carries to the fragment stage. + Uint32 distanceCount = 0; + // False: the producer had no enables uniform, so every distance clips. + Bool enablesFound = false; + String declineDetail; + }; + + // Emulates gl_ClipDistance across a WHOLE program on a backend that cannot clip + // natively (BackendCaps::EmulatesClipDistance): every stage's accesses move onto + // a Private shadow, the last pre-rasterization stage carries + // `enabled(i) ? d[i] : 1.0` to the fragment stage in ceil(N/4) vec4 varyings + // placed past every location the program uses, and the fragment stage discards + // where a carried distance is negative. See EmulateClipDistancePass for the + // per-module rewrite. Runs BEFORE the sanitize chain (ProgramSpirvTask): the + // reserved enables uniform is unread until this pass reads it, and ADCE would + // otherwise delete a global UBO that holds nothing else. Atomic: every stage is + // rewritten or none is. + // `captureRequestsClipDistance` declines (a capture would need the unmasked + // value at a varying the pass does not create); `maxVaryingLocations` is the + // carrier budget. Returns false only when the optimizer itself failed. + static Bool EmulateClipDistanceForProgram(Vector>& modules, + const Vector& shaderTypes, + Bool captureRequestsClipDistance, Uint32 maxVaryingLocations, + ClipDistanceEmulationOutcome& outcome, + bool validateOutput = true, + bool enableSpirvValidation = false); + // True when the module still declares a 64-bit float type. After // SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the // module (see its header for the two operations that make it decline), which is diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp index f305b61c..5f9f9cfa 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp @@ -1880,6 +1880,27 @@ namespace { source.insert(afterVersion.Get(source), shim); } + // On a backend that emulates gl_ClipDistance, the stage that ends up producing the clip + // carriers needs GL's per-distance enables, and the only thing a module can read per draw + // without a pipeline key is a uniform. Declared in every pre-rasterization stage that names + // the built-in, because which of them is LAST is a link-time fact this per-shader pass cannot + // know; the stages that are not the producer simply never read it, and glslang folds the one + // name into the shared MGL_GLOBAL_UBO for every stage of the program alike. + void InjectClipDistanceEnablesShim(MobileGL::ShaderStage stage, MobileGL::String& source, + AfterVersionAnchor& afterVersion) { + if (stage != MobileGL::ShaderStage::Vertex && stage != MobileGL::ShaderStage::TessControl && + stage != MobileGL::ShaderStage::TessEval && stage != MobileGL::ShaderStage::Geometry) { + return; + } + if (source.find("gl_ClipDistance") == MobileGL::String::npos) return; + const MobileGL::String masked = MaskCommentsAndQuotedText(source); + if (!MaskedSourceHasIdentifier(masked, "gl_ClipDistance")) return; + if (MaskedSourceHasIdentifier(masked, MobileGL::MG_Util::ShaderTranspiler::CLIP_DISTANCE_ENABLES_UNIFORM_NAME)) { + return; + } + source.insert(afterVersion.Get(source), "uniform uint mg_ClipDistanceEnables;\n"); + } + // The uniforms `text` declares with the external sampler type, and (into `others`) the names it // declares with any other type - the same name can be both in a source whose #if branches pick // one (a compositor's shader generator does exactly that). @@ -2020,6 +2041,7 @@ namespace MobileGL { ModernizeLegacyGLSL(stage, source, afterVersion); InjectDepthRangeBuiltinShim(stage, source, afterVersion); InjectNumSamplesBuiltinShim(stage, originalLanguage, source, afterVersion); + if (env.EmulatesClipDistance()) InjectClipDistanceEnablesShim(stage, source, afterVersion); } Vector ExternalSamplerUniformNames(const String& preprocessedSource) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.cpp new file mode 100644 index 00000000..10952b94 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.cpp @@ -0,0 +1,699 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.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 + +#include "EmulateClipDistancePass.h" + +#include "spirv.hpp" +#include "source/opt/basic_block.h" +#include "source/opt/build_module.h" +#include "source/opt/constants.h" +#include "source/opt/def_use_manager.h" +#include "source/opt/function.h" +#include "source/opt/instruction.h" +#include "source/opt/ir_builder.h" +#include "source/opt/ir_context.h" +#include "source/opt/module.h" +#include "source/opt/type_manager.h" +#include "source/opt/types.h" +#include "source/util/make_unique.h" +#include "source/util/string_utils.h" + +#include +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::BasicBlock; + using spvtools::opt::Function; + using spvtools::opt::Instruction; + using spvtools::opt::InstructionBuilder; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + namespace analysis = spvtools::opt::analysis; + + const IRContext::Analysis kBuilderAnalyses = static_cast( + IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); + + Instruction* EntryPoint(IRContext* ctx) { + for (Instruction& ep : ctx->module()->entry_points()) { + return &ep; + } + return nullptr; + } + + Function* EntryFunction(IRContext* ctx, Instruction* entryPoint) { + const uint32_t id = entryPoint->GetSingleWordInOperand(1); + for (auto& function : *ctx->module()) { + if (function.result_id() == id) return &function; + } + return nullptr; + } + + uint32_t PointeeOf(IRContext* ctx, uint32_t pointerTypeId) { + Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(pointerTypeId); + if (ptrType == nullptr || ptrType->opcode() != spv::Op::OpTypePointer) return 0; + return ptrType->GetSingleWordInOperand(1); + } + + bool PlainConstantValue(IRContext* ctx, uint32_t id, uint32_t& outValue) { + Instruction* def = ctx->get_def_use_mgr()->GetDef(id); + if (def == nullptr || def->opcode() != spv::Op::OpConstant) return false; + if (def->NumInOperands() != 1) return false; + outValue = def->GetSingleWordInOperand(0); + return true; + } + + // Length of an OpTypeArray of 32-bit float with a plain constant length, else 0. + uint32_t FloatArrayLength(IRContext* ctx, uint32_t typeId) { + Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId); + if (t == nullptr || t->opcode() != spv::Op::OpTypeArray) return 0; + Instruction* element = ctx->get_def_use_mgr()->GetDef(t->GetSingleWordInOperand(0)); + if (element == nullptr || element->opcode() != spv::Op::OpTypeFloat || + element->GetSingleWordInOperand(0) != 32) { + return 0; + } + uint32_t length = 0; + if (!PlainConstantValue(ctx, t->GetSingleWordInOperand(1), length)) return 0; + return length; + } + + void AddNameFor(IRContext* ctx, uint32_t id, const String& name) { + std::vector operands; + operands.push_back({SPV_OPERAND_TYPE_ID, {id}}); + operands.push_back({SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(name)}); + ctx->AddDebug2Inst(spvtools::MakeUnique(ctx, spv::Op::OpName, 0, 0, operands)); + } + + uint32_t AddGlobalVariable(IRContext* ctx, uint32_t pointeeTypeId, spv::StorageClass storage, + const String& name) { + const uint32_t ptrTypeId = ctx->get_type_mgr()->FindPointerToType(pointeeTypeId, storage); + if (ptrTypeId == 0) return 0; + const uint32_t varId = ctx->TakeNextId(); + auto var = spvtools::MakeUnique( + ctx, spv::Op::OpVariable, ptrTypeId, varId, + std::initializer_list{ + {SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast(storage)}}}); + Instruction* varInst = var.get(); + ctx->AddGlobalValue(std::move(var)); + ctx->get_def_use_mgr()->AnalyzeInstDefUse(varInst); + AddNameFor(ctx, varId, name); + return varId; + } + + void AddLocationDecoration(IRContext* ctx, uint32_t id, uint32_t location) { + ctx->AddAnnotationInst(spvtools::MakeUnique( + ctx, spv::Op::OpDecorate, 0, 0, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {id}}, + {SPV_OPERAND_TYPE_DECORATION, {static_cast(spv::Decoration::Location)}}, + {SPV_OPERAND_TYPE_LITERAL_INTEGER, {location}}})); + } + + void RemoveFromInterface(Instruction* entryPoint, uint32_t id) { + Instruction::OperandList kept; + for (uint32_t i = 0; i < entryPoint->NumInOperands(); ++i) { + const Operand& operand = entryPoint->GetInOperand(i); + if (i >= 3 && operand.type == SPV_OPERAND_TYPE_ID && operand.words.size() == 1 && + operand.words[0] == id) { + continue; + } + kept.push_back(operand); + } + entryPoint->SetInOperands(Move(kept)); + } + + // Whether every use of a pointer into the built-in is one the retype below can + // follow: loads, stores (as the POINTER), copies, and further access chains. + bool PointerUsesAreRetypable(IRContext* ctx, Instruction* pointer, String& reason) { + bool ok = true; + ctx->get_def_use_mgr()->ForEachUse(pointer, [&](Instruction* user, uint32_t operandIndex) { + if (!ok) return; + switch (user->opcode()) { + case spv::Op::OpName: + case spv::Op::OpDecorate: + case spv::Op::OpEntryPoint: + return; + case spv::Op::OpLoad: + case spv::Op::OpCopyMemory: + return; + case spv::Op::OpStore: + // Operand 0 of OpStore is the pointer; anything else would be + // storing the pointer itself, which logical addressing forbids. + if (operandIndex != 0) { + ok = false; + reason = "a pointer into gl_ClipDistance stored as a value"; + } + return; + case spv::Op::OpAccessChain: + case spv::Op::OpInBoundsAccessChain: + // Base operand: the instruction's first id after result type/id. + if (operandIndex != 2) { + ok = false; + reason = "a pointer into gl_ClipDistance used as an access-chain index"; + return; + } + if (!PointerUsesAreRetypable(ctx, user, reason)) ok = false; + return; + default: + ok = false; + reason = std::format("a pointer into gl_ClipDistance reaching SPIR-V opcode {}", + static_cast(user->opcode())); + return; + } + }); + return ok; + } + + // Re-points every access chain hanging off `pointer` (which now lives in Private + // storage) at a Private pointer type of the same pointee. + void RetypeChainUsers(IRContext* ctx, Instruction* pointer) { + std::vector chains; + ctx->get_def_use_mgr()->ForEachUser(pointer, [&](Instruction* user) { + if ((user->opcode() == spv::Op::OpAccessChain || + user->opcode() == spv::Op::OpInBoundsAccessChain) && + user->GetSingleWordInOperand(0) == pointer->result_id()) { + chains.push_back(user); + } + }); + for (Instruction* chain : chains) { + const uint32_t pointee = PointeeOf(ctx, chain->type_id()); + chain->SetResultType( + ctx->get_type_mgr()->FindPointerToType(pointee, spv::StorageClass::Private)); + ctx->get_def_use_mgr()->AnalyzeInstUse(chain); + RetypeChainUsers(ctx, chain); + } + } + + // A scalar constant of the given 32-bit integer type. + uint32_t IntConstantOfType(IRContext* ctx, uint32_t intTypeId, uint32_t value) { + const analysis::Type* type = ctx->get_type_mgr()->GetType(intTypeId); + const analysis::Integer* integer = type != nullptr ? type->AsInteger() : nullptr; + if (integer != nullptr && integer->IsSigned()) { + return ctx->get_constant_mgr()->GetSIntConstId(static_cast(value)); + } + return ctx->get_constant_mgr()->GetUIntConstId(value); + } + + bool IsInt32Type(IRContext* ctx, uint32_t typeId) { + const analysis::Type* type = ctx->get_type_mgr()->GetType(typeId); + const analysis::Integer* integer = type != nullptr ? type->AsInteger() : nullptr; + return integer != nullptr && integer->width() == 32; + } + + struct ClipSites { + // struct type id -> member index of its BuiltIn ClipDistance member + std::vector> members; + std::unordered_set standaloneVars; + Instruction* capability = nullptr; + }; + + ClipSites FindClipSites(IRContext* ctx) { + ClipSites sites; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 && + static_cast(ann.GetSingleWordInOperand(2)) == spv::Decoration::BuiltIn && + static_cast(ann.GetSingleWordInOperand(3)) == spv::BuiltIn::ClipDistance) { + sites.members.emplace_back(ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)); + } else if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 3 && + static_cast(ann.GetSingleWordInOperand(1)) == + spv::Decoration::BuiltIn && + static_cast(ann.GetSingleWordInOperand(2)) == + spv::BuiltIn::ClipDistance) { + Instruction* var = ctx->get_def_use_mgr()->GetDef(ann.GetSingleWordInOperand(0)); + if (var != nullptr && var->opcode() == spv::Op::OpVariable) { + sites.standaloneVars.insert(var->result_id()); + } + } + } + for (Instruction& capability : ctx->module()->capabilities()) { + if (capability.NumInOperands() >= 1 && + static_cast(capability.GetSingleWordInOperand(0)) == + spv::Capability::ClipDistance) { + sites.capability = &capability; + } + } + return sites; + } + + bool ClipMemberOf(const ClipSites& sites, uint32_t structId, uint32_t& member) { + for (const auto& [id, index] : sites.members) { + if (id == structId) { + member = index; + return true; + } + } + return false; + } + } // namespace + + ClipDistanceModuleProbe EmulateClipDistancePass::ProbeModule(const Vector& spirv) { + ClipDistanceModuleProbe probe; + if (spirv.empty()) { + probe.parsed = true; + return probe; + } + std::unique_ptr ctx = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, [](spv_message_level_t, const char*, const spv_position_t&, const char*) {}, + spirv.data(), spirv.size()); + if (!ctx) return probe; + probe.parsed = true; + const ClipSites sites = FindClipSites(ctx.get()); + probe.declaresClipDistanceCapability = sites.capability != nullptr; + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable) continue; + if (static_cast(inst.GetSingleWordInOperand(0)) != spv::StorageClass::Output) { + continue; + } + const uint32_t pointee = PointeeOf(ctx.get(), inst.type_id()); + if (sites.standaloneVars.count(inst.result_id()) != 0) { + probe.outputDistanceCount = FloatArrayLength(ctx.get(), pointee); + break; + } + Instruction* type = ctx->get_def_use_mgr()->GetDef(pointee); + uint32_t member = 0; + if (type != nullptr && type->opcode() == spv::Op::OpTypeStruct && + ClipMemberOf(sites, type->result_id(), member)) { + probe.outputDistanceCount = FloatArrayLength(ctx.get(), type->GetSingleWordInOperand(member)); + break; + } + } + return probe; + } + + spvtools::opt::Pass::Status EmulateClipDistancePass::Process() { + IRContext* ctx = context(); + auto* defUse = ctx->get_def_use_mgr(); + auto* typeMgr = ctx->get_type_mgr(); + auto* constMgr = ctx->get_constant_mgr(); + using Role = EmulateClipDistanceOptions::Role; + + Instruction* entryPoint = EntryPoint(ctx); + if (entryPoint == nullptr) return Status::SuccessWithoutChange; + const auto model = static_cast(entryPoint->GetSingleWordInOperand(0)); + const bool isFragment = model == spv::ExecutionModel::Fragment; + const bool isGeometry = model == spv::ExecutionModel::Geometry; + const bool isPreRaster = model == spv::ExecutionModel::Vertex || + model == spv::ExecutionModel::TessellationControl || + model == spv::ExecutionModel::TessellationEvaluation || isGeometry; + + const auto decline = [&](String reason) { + if (m_report != nullptr) { + m_report->declined = true; + m_report->declineReason = Move(reason); + } + return Status::SuccessWithoutChange; + }; + if (m_options.role == Role::Consumer && !isFragment) return decline("consumer role on a non-fragment stage"); + if (m_options.role != Role::Consumer && !isPreRaster) { + return decline("producer/redirect role on a non-pre-rasterization stage"); + } + if (m_options.role == Role::Producer && model == spv::ExecutionModel::TessellationControl) { + return decline("a control stage cannot be the last pre-rasterization stage"); + } + + const ClipSites sites = FindClipSites(ctx); + + // ---- Phase 1: ANALYSIS ONLY. A decline leaves the module byte-identical. ---- + std::vector memberChains; // [clipMember, rest...] on a non-arrayed block + std::vector standaloneVars; // standalone ClipDistance variables + uint32_t shadowTypeId = 0; + String reason; + const auto noteShadowType = [&](uint32_t typeId) { + if (shadowTypeId == 0) { + shadowTypeId = typeId; + return true; + } + return shadowTypeId == typeId; + }; + + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable) continue; + const auto storage = static_cast(inst.GetSingleWordInOperand(0)); + if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) continue; + const uint32_t pointeeId = PointeeOf(ctx, inst.type_id()); + + if (sites.standaloneVars.count(inst.result_id()) != 0) { + if (FloatArrayLength(ctx, pointeeId) == 0) { + return decline("a standalone gl_ClipDistance that is not a sized float array"); + } + // A pre-rasterization stage's standalone INPUT would be gl_in data, + // which a non-arrayed variable cannot be; only the fragment stage reads + // gl_ClipDistance this way. + if (storage == spv::StorageClass::Input && !isFragment) { + return decline("a standalone gl_ClipDistance input outside the fragment stage"); + } + if (!noteShadowType(pointeeId)) return decline("two differently sized gl_ClipDistance arrays"); + if (!PointerUsesAreRetypable(ctx, &inst, reason)) return decline(Move(reason)); + standaloneVars.push_back(&inst); + continue; + } + + Instruction* pointee = defUse->GetDef(pointeeId); + if (pointee == nullptr) continue; + bool arrayed = false; + if (pointee->opcode() == spv::Op::OpTypeArray) { + arrayed = true; + pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0)); + if (pointee == nullptr) continue; + } + uint32_t clipMember = 0; + if (pointee->opcode() != spv::Op::OpTypeStruct || !ClipMemberOf(sites, pointee->result_id(), clipMember)) { + continue; + } + const uint32_t memberTypeId = pointee->GetSingleWordInOperand(clipMember); + const uint32_t memberOperand = arrayed ? 2u : 1u; // [vertex,] member, ... + bool declined = false; + defUse->ForEachUser(&inst, [&](Instruction* user) { + if (declined) return; + switch (user->opcode()) { + case spv::Op::OpEntryPoint: + case spv::Op::OpName: + case spv::Op::OpDecorate: + return; + case spv::Op::OpAccessChain: + case spv::Op::OpInBoundsAccessChain: { + if (user->NumInOperands() <= memberOperand) { + // Stops short of the member: a whole block (or gl_in[i]) + // pointer. Harmless unless something then reaches the clip + // member through it or copies the whole struct. + bool touches = false; + defUse->ForEachUser(user, [&](Instruction* next) { + if (next->opcode() == spv::Op::OpAccessChain || + next->opcode() == spv::Op::OpInBoundsAccessChain) { + uint32_t member = 0; + if (next->NumInOperands() < 2 || + !PlainConstantValue(ctx, next->GetSingleWordInOperand(1), member) || + member == clipMember) { + touches = true; + } + } else if (next->opcode() != spv::Op::OpName) { + touches = true; // whole-struct load/store/copy + } + }); + if (touches) { + declined = true; + reason = "a split access chain or whole-gl_PerVertex copy reaching gl_ClipDistance"; + } + return; + } + uint32_t member = 0; + if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(memberOperand), member)) { + declined = true; + reason = "a non-constant gl_PerVertex member index"; + return; + } + if (member != clipMember) return; // another member + if (arrayed) { + declined = true; + reason = storage == spv::StorageClass::Input + ? "a gl_in[].gl_ClipDistance read" + : "a gl_out[].gl_ClipDistance write"; + return; + } + if (!noteShadowType(memberTypeId)) { + declined = true; + reason = "two differently sized gl_ClipDistance arrays"; + return; + } + if (!PointerUsesAreRetypable(ctx, user, reason)) { + declined = true; + return; + } + memberChains.push_back(user); + return; + } + case spv::Op::OpLoad: + case spv::Op::OpStore: + case spv::Op::OpCopyMemory: + declined = true; + reason = "a whole-aggregate load/store/copy of the gl_PerVertex interface"; + return; + default: + declined = true; + reason = std::format("SPIR-V opcode {} reaching the gl_PerVertex interface", + static_cast(user->opcode())); + return; + } + }); + if (declined) return decline(Move(reason)); + } + + const bool hasAccesses = !memberChains.empty() || !standaloneVars.empty(); + if (shadowTypeId == 0 && m_options.role == Role::Producer) { + // A producer with nothing redirected may still have declared the carrier + // length the driver planned with; take it from its Output declaration. + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable || + static_cast(inst.GetSingleWordInOperand(0)) != spv::StorageClass::Output) { + continue; + } + Instruction* type = defUse->GetDef(PointeeOf(ctx, inst.type_id())); + uint32_t member = 0; + if (type != nullptr && type->opcode() == spv::Op::OpTypeStruct && + ClipMemberOf(sites, type->result_id(), member)) { + shadowTypeId = type->GetSingleWordInOperand(member); + break; + } + } + } + const uint32_t shadowLength = shadowTypeId != 0 ? FloatArrayLength(ctx, shadowTypeId) : 0; + if (shadowTypeId != 0 && shadowLength == 0) return decline("gl_ClipDistance is not a sized float array"); + + Uint32 producedCount = 0; + if (m_options.role == Role::Producer) { + producedCount = std::min(shadowLength, kMaxCarriers * 4); + if (shadowLength > kMaxCarriers * 4) return decline("more than eight clip distances"); + } + const Uint32 consumedCount = + m_options.role == Role::Consumer ? std::min(m_options.distanceCount, kMaxCarriers * 4) : 0; + + // The producer's enables uniform: a member of a Uniform-storage struct, by name. + uint32_t enablesVarId = 0; + uint32_t enablesMemberIndex = 0; + uint32_t enablesTypeId = 0; + if (producedCount > 0 && !m_options.enablesMemberName.empty()) { + for (Instruction& debug : ctx->module()->debugs2()) { + if (enablesVarId != 0) break; + if (debug.opcode() != spv::Op::OpMemberName || debug.NumInOperands() < 3) continue; + if (debug.GetOperand(2).AsString() != m_options.enablesMemberName) continue; + const uint32_t structId = debug.GetSingleWordInOperand(0); + const uint32_t member = debug.GetSingleWordInOperand(1); + Instruction* structType = defUse->GetDef(structId); + if (structType == nullptr || structType->opcode() != spv::Op::OpTypeStruct || + member >= structType->NumInOperands()) { + continue; + } + const uint32_t memberType = structType->GetSingleWordInOperand(member); + if (!IsInt32Type(ctx, memberType)) continue; + for (Instruction& var : ctx->module()->types_values()) { + if (var.opcode() != spv::Op::OpVariable || + static_cast(var.GetSingleWordInOperand(0)) != + spv::StorageClass::Uniform || + PointeeOf(ctx, var.type_id()) != structId) { + continue; + } + enablesVarId = var.result_id(); + enablesMemberIndex = member; + enablesTypeId = memberType; + break; + } + } + } + + if (!hasAccesses && sites.capability == nullptr && producedCount == 0 && consumedCount == 0) { + return Status::SuccessWithoutChange; + } + + // ---- Phase 2: MUTATION. Nothing below may decline. ---- + const bool interfaceListsAllGlobals = ctx->module()->version() >= 0x00010400u; + uint32_t shadowId = 0; + if (shadowTypeId != 0 && (hasAccesses || producedCount > 0)) { + shadowId = AddGlobalVariable(ctx, shadowTypeId, spv::StorageClass::Private, kShadowName); + if (interfaceListsAllGlobals) entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {shadowId}}); + } + + for (Instruction* chain : memberChains) { + if (chain->NumInOperands() == 2) { + // The chain IS the whole array: every use now addresses the shadow. + ctx->ReplaceAllUsesWith(chain->result_id(), shadowId); + ctx->KillInst(chain); + continue; + } + Instruction::OperandList operands; + operands.push_back({SPV_OPERAND_TYPE_ID, {shadowId}}); + for (uint32_t i = 2; i < chain->NumInOperands(); ++i) { + operands.push_back(chain->GetInOperand(i)); + } + chain->SetInOperands(Move(operands)); + chain->SetResultType( + typeMgr->FindPointerToType(PointeeOf(ctx, chain->type_id()), spv::StorageClass::Private)); + defUse->AnalyzeInstUse(chain); + RetypeChainUsers(ctx, chain); + } + for (Instruction* var : standaloneVars) { + const uint32_t varId = var->result_id(); + ctx->KillNamesAndDecorates(varId); + RemoveFromInterface(entryPoint, varId); + ctx->ReplaceAllUsesWith(varId, shadowId); + ctx->KillInst(var); + } + if (shadowId != 0) RetypeChainUsers(ctx, defUse->GetDef(shadowId)); + if (sites.capability != nullptr) ctx->KillInst(sites.capability); + + analysis::Float floatTy(32); + const uint32_t floatTypeId = typeMgr->GetTypeInstruction(&floatTy); + const analysis::Type* floatReg = typeMgr->GetRegisteredType(&floatTy); + analysis::Vector vec4Ty(floatReg, 4); + const uint32_t vec4TypeId = typeMgr->GetTypeInstruction(&vec4Ty); + analysis::Bool boolTy; + const uint32_t boolTypeId = typeMgr->GetTypeInstruction(&boolTy); + const uint32_t oneId = constMgr->GetFloatConstId(1.0f); + const uint32_t zeroId = constMgr->GetFloatConstId(0.0f); + const uint32_t ptrPrivateFloat = typeMgr->FindPointerToType(floatTypeId, spv::StorageClass::Private); + + // ---- producer: write the carriers ---- + if (producedCount > 0) { + const Uint32 carrierCount = (producedCount + 3) / 4; + uint32_t carriers[kMaxCarriers] = {0, 0}; + for (Uint32 k = 0; k < carrierCount; ++k) { + carriers[k] = AddGlobalVariable(ctx, vec4TypeId, spv::StorageClass::Output, CarrierName(k)); + AddLocationDecoration(ctx, carriers[k], m_options.location + k); + entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {carriers[k]}}); + } + uint32_t ptrUniformEnables = 0; + uint32_t enablesMemberConst = 0; + if (enablesVarId != 0) { + ptrUniformEnables = typeMgr->FindPointerToType(enablesTypeId, spv::StorageClass::Uniform); + enablesMemberConst = constMgr->GetSIntConstId(static_cast(enablesMemberIndex)); + if (interfaceListsAllGlobals) { + bool listed = false; + for (uint32_t i = 3; i < entryPoint->NumInOperands(); ++i) { + if (entryPoint->GetSingleWordInOperand(i) == enablesVarId) listed = true; + } + if (!listed) entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {enablesVarId}}); + } + } + + std::vector flushSites; + if (isGeometry) { + for (auto& function : *ctx->module()) { + function.ForEachInst([&](Instruction* inst) { + if (inst->opcode() == spv::Op::OpEmitVertex || + inst->opcode() == spv::Op::OpEmitStreamVertex) { + flushSites.push_back(inst); + } + }); + } + } else if (Function* entry = EntryFunction(ctx, entryPoint)) { + entry->ForEachInst([&](Instruction* inst) { + if (inst->opcode() == spv::Op::OpReturn || inst->opcode() == spv::Op::OpReturnValue) { + flushSites.push_back(inst); + } + }); + } + for (Instruction* site : flushSites) { + InstructionBuilder b(ctx, site, kBuilderAnalyses); + uint32_t maskId = 0; + if (enablesVarId != 0) { + Instruction* ptr = b.AddAccessChain(ptrUniformEnables, enablesVarId, {enablesMemberConst}); + maskId = b.AddLoad(enablesTypeId, ptr->result_id())->result_id(); + } + std::vector values; + for (Uint32 i = 0; i < producedCount; ++i) { + Instruction* ptr = b.AddAccessChain(ptrPrivateFloat, shadowId, {constMgr->GetUIntConstId(i)}); + uint32_t value = b.AddLoad(floatTypeId, ptr->result_id())->result_id(); + if (maskId != 0) { + const uint32_t bit = + b.AddBinaryOp(enablesTypeId, spv::Op::OpBitwiseAnd, maskId, + IntConstantOfType(ctx, enablesTypeId, 1u << i)) + ->result_id(); + const uint32_t on = b.AddBinaryOp(boolTypeId, spv::Op::OpINotEqual, bit, + IntConstantOfType(ctx, enablesTypeId, 0u)) + ->result_id(); + value = b.AddSelect(floatTypeId, on, value, oneId)->result_id(); + } + values.push_back(value); + } + while (values.size() % 4 != 0) values.push_back(oneId); + for (Uint32 k = 0; k < carrierCount; ++k) { + const uint32_t composite = + b.AddCompositeConstruct(vec4TypeId, {values[k * 4], values[k * 4 + 1], values[k * 4 + 2], + values[k * 4 + 3]}) + ->result_id(); + b.AddStore(carriers[k], composite); + } + } + if (m_report != nullptr) { + m_report->distanceCount = producedCount; + m_report->enablesFound = enablesVarId != 0; + } + } + + // ---- consumer: read the carriers, discard, serve gl_ClipDistance reads ---- + if (m_options.role == Role::Consumer && consumedCount > 0) { + Function* entry = EntryFunction(ctx, entryPoint); + if (entry != nullptr && entry->begin() != entry->end()) { + const Uint32 carrierCount = (consumedCount + 3) / 4; + uint32_t carriers[kMaxCarriers] = {0, 0}; + for (Uint32 k = 0; k < carrierCount; ++k) { + carriers[k] = AddGlobalVariable(ctx, vec4TypeId, spv::StorageClass::Input, CarrierName(k)); + AddLocationDecoration(ctx, carriers[k], m_options.location + k); + entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {carriers[k]}}); + } + BasicBlock* head = &*entry->begin(); + auto split = head->begin(); + while (split != head->end() && split->opcode() == spv::Op::OpVariable) ++split; + BasicBlock* rest = head->SplitBasicBlock(ctx, ctx->TakeNextId(), split); + + InstructionBuilder b(ctx, head, kBuilderAnalyses); + uint32_t loaded[kMaxCarriers] = {0, 0}; + for (Uint32 k = 0; k < carrierCount; ++k) { + loaded[k] = b.AddLoad(vec4TypeId, carriers[k])->result_id(); + } + uint32_t anyNegative = 0; + for (Uint32 i = 0; i < consumedCount; ++i) { + const uint32_t component = + b.AddCompositeExtract(floatTypeId, loaded[i / 4], {i % 4})->result_id(); + if (shadowId != 0 && i < shadowLength) { + Instruction* ptr = + b.AddAccessChain(ptrPrivateFloat, shadowId, {constMgr->GetUIntConstId(i)}); + b.AddStore(ptr->result_id(), component); + } + const uint32_t negative = + b.AddBinaryOp(boolTypeId, spv::Op::OpFOrdLessThan, component, zeroId)->result_id(); + anyNegative = anyNegative == 0 + ? negative + : b.AddBinaryOp(boolTypeId, spv::Op::OpLogicalOr, anyNegative, negative) + ->result_id(); + } + const uint32_t killLabel = ctx->TakeNextId(); + b.AddConditionalBranch(anyNegative, killLabel, rest->id(), rest->id()); + + auto killBlock = spvtools::MakeUnique(spvtools::MakeUnique( + ctx, spv::Op::OpLabel, 0, killLabel, std::initializer_list{})); + killBlock->AddInstruction(spvtools::MakeUnique( + ctx, spv::Op::OpKill, 0, 0, std::initializer_list{})); + entry->InsertBasicBlockAfter(std::move(killBlock), head); + } + } + + if (m_report != nullptr) m_report->rewrote = true; + ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken EmulateClipDistancePass::CreateEmulateClipDistancePass( + EmulateClipDistanceOptions options, EmulateClipDistanceReport* report) { + return spvtools::Optimizer::PassToken(MakeUnique(Move(options), report)); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.h new file mode 100644 index 00000000..59e42299 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.h @@ -0,0 +1,128 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateClipDistancePass.h +// 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 + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // Emulates gl_ClipDistance on a backend that cannot host the built-in at all: an ES + // driver without GL_EXT_clip_cull_distance, or a Vulkan device with + // shaderClipDistance == VK_FALSE (every Mali GPU measured so far is both). GL 3.0+ + // requires GL_MAX_CLIP_DISTANCES >= 8, so a frontend that reports GL 4.6 cannot + // honestly answer 0 there, and answering 8 without this pass makes every clipping + // draw either fail to compile or render unclipped. + // + // WHAT THE EMULATION IS. Clip distance d is linear in clip space, so its + // perspective-correct interpolant at a fragment is exactly the distance the + // rasterizer's clipper would have evaluated there; discarding the fragments where it + // is negative removes the same pixels hardware clipping removes. So: + // - every access to the ClipDistance built-in, in every stage, is redirected onto + // a Private float[N] shadow (mg_ClipDistanceShadow) and the ClipDistance + // capability is stripped - the gl_PerVertex member stays declared but unaccessed, + // which is the shape glslang emits for every program that never clips; + // - the LAST pre-rasterization stage (the PRODUCER) writes + // `enabled(i) ? shadow[i] : 1.0` into ceil(N/4) ordinary vec4 varyings + // (mg_ClipDistanceEmu0/1) at every return of its entry function - before every + // EmitVertex in a geometry stage, which is when one vertex's outputs latch. + // enabled(i) is bit i of the reserved default-block uniform the source pipeline + // declared (CLIP_DISTANCE_ENABLES_UNIFORM_NAME), which the draw path refreshes + // from GL_CLIP_DISTANCE0..7 before every draw. Applying the enables HERE keeps + // the varyings free of pipeline-key state and makes a disabled distance - even + // one the shader writes negative - clip nothing, as GL requires; + // - the fragment stage (the CONSUMER) reads the carriers before anything else and + // executes OpKill when any carried distance is negative. Its own gl_ClipDistance + // reads are served from the same carriers (GLSL leaves the disabled elements + // undefined there, so the 1.0 they carry is a legal value). + // - every other stage that touches the built-in (a vertex stage ahead of a + // geometry stage, say) only has its accesses redirected: in GL those writes are + // dead unless a later stage reads gl_in[].gl_ClipDistance. + // + // WHAT IT COSTS: two varying locations past every location the program uses, and a + // discard in the fragment stage of every clipping program (early depth/stencil is + // lost for those programs). Cull distance is NOT emulated: culling is per primitive, + // which a per-fragment discard cannot reproduce. + // + // WHAT DECLINES (the module is left byte-identical and the report says why): + // gl_in[].gl_ClipDistance reads and gl_out[].gl_ClipDistance writes (arrayed + // per-vertex access in tessellation/geometry stages), a non-constant gl_PerVertex + // member index, whole-gl_PerVertex loads/stores/copies, and a pointer into the + // built-in that escapes into an opcode the pass cannot retype (a function argument). + // + // One module per run; the program-wide contract (one shared location, which stage + // is the producer, every stage rewritten or none) is owned by + // ShaderCompiler::EmulateClipDistanceForProgram, the only caller. + struct EmulateClipDistanceOptions { + enum class Role : Uint8 { + // Redirect the accesses and strip the capability; no carriers. + Redirect, + // The last pre-rasterization stage: redirect, then write the carriers. + Producer, + // The fragment stage: read the carriers, discard, serve gl_ClipDistance reads. + Consumer, + }; + Role role = Role::Redirect; + // The first carrier location; carrier k sits at location + k. + Uint32 location = 0; + // Consumer only: how many distances the producer carries (0 = none, in which + // case the stage only has its own reads redirected). + Uint32 distanceCount = 0; + // Producer only: the default-block uniform member holding the enable bits. When + // the module has no such member every distance counts as enabled. + String enablesMemberName; + }; + + struct EmulateClipDistanceReport { + Bool declined = false; + String declineReason; + Bool rewrote = false; + // Producer: how many distances it now carries. + Uint32 distanceCount = 0; + // Producer: whether the enables uniform was found (false = all-enabled fallback). + Bool enablesFound = false; + }; + + // What one module looks like to the program-wide driver, from a single parse. + struct ClipDistanceModuleProbe { + Bool parsed = false; + // glslang declares the capability on first ACCESS, so this is the trigger. + Bool declaresClipDistanceCapability = false; + // The length of the module's non-arrayed ClipDistance OUTPUT (block member or + // standalone variable), or 0 when it declares none. + Uint32 outputDistanceCount = 0; + }; + + class EmulateClipDistancePass : public spvtools::opt::Pass { + public: + static constexpr const char* kShadowName = "mg_ClipDistanceShadow"; + static constexpr Uint32 kMaxCarriers = 2; + static const char* CarrierName(Uint32 index) { + return index == 0 ? "mg_ClipDistanceEmu0" : "mg_ClipDistanceEmu1"; + } + + EmulateClipDistancePass(EmulateClipDistanceOptions options, EmulateClipDistanceReport* report) + : m_options(Move(options)), m_report(report) {} + const char* name() const override { return "mobilegl-emulate-clip-distance"; } + Status Process() override; + + static ClipDistanceModuleProbe ProbeModule(const Vector& spirv); + + static spvtools::Optimizer::PassToken CreateEmulateClipDistancePass( + EmulateClipDistanceOptions options, EmulateClipDistanceReport* report); + + private: + EmulateClipDistanceOptions m_options; + EmulateClipDistanceReport* m_report; + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/Types.h b/MobileGL/MG_Util/ShaderTranspiler/Types.h index 785f5a45..ab52c8ff 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/Types.h +++ b/MobileGL/MG_Util/ShaderTranspiler/Types.h @@ -28,6 +28,14 @@ namespace MobileGL { // uses gl_NumSamples the link task will still hide its uniform. That is the same // bargain every mg_-prefixed rewrite in this pipeline strikes. inline const char* NUM_SAMPLES_UNIFORM_NAME = "mg_NumSamples"; + // The default-block uniform InjectClipDistanceEnablesShim declares, on a backend that + // emulates gl_ClipDistance (BackendCaps::EmulatesClipDistance), in every + // pre-rasterization stage that names the built-in: bit i is GL_CLIP_DISTANCE0 + i's + // enable. EmulateClipDistancePass reads it where the producing stage writes its + // carriers; the draw path refreshes it from the render state before every draw, and + // the link task hides it from the GL uniform surface exactly as it hides + // NUM_SAMPLES_UNIFORM_NAME. Reserved on the same terms. + inline const char* CLIP_DISTANCE_ENABLES_UNIFORM_NAME = "mg_ClipDistanceEnables"; // glslang's Vulkan-relaxed parse rewrites every atomic_uint into a member of a // synthesized storage block named "_" // (ParseContextBase::growAtomicCounterBlock). That block IS the GL atomic counter