[Feat] (MG_Util/ShaderTranspiler, MG_Backend, MG_Util/SelfTest): emulate gl_ClipDistance where the backend cannot clip natively and report it in POST

This commit is contained in:
2026-11-07 02:06:25 -04:00
parent d3ec9b8179
commit 6182704c03
35 changed files with 2050 additions and 46 deletions
+1
View File
@@ -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
+19 -10
View File
@@ -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;
+8
View File
@@ -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
+1
View File
@@ -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");
@@ -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
@@ -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
@@ -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
@@ -108,6 +108,21 @@ namespace MobileGL::MG_Impl::GLImpl {
program->WriteReservedNumSamples(static_cast<Int>(std::max<GLint>(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<MG_State::GLState::ProgramObject>& program) {
if (!program || !program->UsesReservedClipDistanceEnables()) return;
Uint32 enables = 0;
for (Uint i = 0; i < 8; ++i) {
const auto cap =
static_cast<CapabilityInput>(static_cast<Uint>(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;
}
@@ -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.
@@ -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<GLenum>(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<GLenum>(GL_CLIP_DISTANCE0 + i));
}
// First with distance 5 enabled too: both halves clipped, by different distances.
glEnable(static_cast<GLenum>(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<GLenum>(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
+1 -1
View File
@@ -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) \
@@ -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)},
@@ -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
@@ -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",
@@ -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
@@ -799,6 +799,27 @@ namespace MobileGL::MG_State::GLState {
MarkUBOContentDirtyRange(offset, static_cast<Uint>(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<SizeT>(offset) + sizeof(Uint32) > spirv.globalUboScratch.size()) return false;
Uint8* const slot = spirv.globalUboScratch.data() + offset;
Uint32 current = 0;
Memcpy(&current, slot, sizeof(Uint32));
if (current == enables) return true;
Memcpy(slot, &enables, sizeof(Uint32));
MarkUBOContentDirtyRange(offset, static_cast<Uint>(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
@@ -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' "
@@ -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
@@ -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<LinkArtifacts>(), 59u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<LinkArtifacts>(), 60u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<StorageBlockReflection>(), 3u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<SpirvArtifacts>(), 8u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<SpirvArtifacts>(), 10u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<ResourceReflection>(), 14u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<XfbVarying>(), 11u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<TypeFacts>(), 20u);
@@ -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<StorageBlockReflection>(), 3u);
EXPECT_EQ(CountFields<LinkArtifacts>(), 59u);
EXPECT_EQ(CountFields<SpirvArtifacts>(), 8u);
EXPECT_EQ(CountFields<LinkArtifacts>(), 60u);
EXPECT_EQ(CountFields<SpirvArtifacts>(), 10u);
}
// A const walk hands the visitor const references, a non-const walk mutable ones: the one
@@ -31,6 +31,7 @@ add_executable(
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
DemotePointSizeTest.cpp
EmulateClipDistanceTest.cpp
FragCoordYFlipTest.cpp
PixelCenterIntegerTest.cpp
ZeroLayerOutputTest.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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#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<Vector<Uint32>> CompileProgramToSpirv(const Vector<Pair<GLenum, const char*>>& stages,
Vector<GLenum>& types) {
using namespace MG_Util::ShaderTranspiler;
Vector<SharedPtr<glslang::TShader>> 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<Vector<Uint32>>& modules) {
for (auto& module : modules) {
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
}
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
String Transpile(const Vector<Uint32>& 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<Uint32>& 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<GLenum> 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<GLenum> 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<GLenum> 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<GLenum> 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<GLenum> 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<GLenum> 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<GLenum> 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<GLenum> 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;
}
@@ -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"
@@ -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
@@ -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<StageSource> 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<StageSource> 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 =====================
@@ -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.
+92
View File
@@ -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
@@ -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
@@ -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 {
@@ -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<Vector<Uint32>>& modules,
const Vector<GLenum>& 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<Int>(i); break;
case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast<Int>(i); break;
case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast<Int>(i); break;
case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast<Int>(i); break;
case GL_FRAGMENT_SHADER: stageIndex[4] = static_cast<Int>(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<Uint32>& 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<Vector<Uint32>> 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<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -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<Vector<Uint32>>& modules,
const Vector<GLenum>& 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
@@ -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<String> ExternalSamplerUniformNames(const String& preprocessedSource) {
@@ -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 <format>
#include <unordered_set>
#include <vector>
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::Analysis>(
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<Operand> 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<Instruction>(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<Instruction>(
ctx, spv::Op::OpVariable, ptrTypeId, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<uint32_t>(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<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {id}},
{SPV_OPERAND_TYPE_DECORATION, {static_cast<uint32_t>(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<uint32_t>(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<Instruction*> 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<int32_t>(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<std::pair<uint32_t, uint32_t>> members;
std::unordered_set<uint32_t> 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<spv::Decoration>(ann.GetSingleWordInOperand(2)) == spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(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<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(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<spv::Capability>(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<Uint32>& spirv) {
ClipDistanceModuleProbe probe;
if (spirv.empty()) {
probe.parsed = true;
return probe;
}
std::unique_ptr<IRContext> 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<spv::StorageClass>(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<spv::ExecutionModel>(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<Instruction*> memberChains; // [clipMember, rest...] on a non-arrayed block
std::vector<Instruction*> 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<spv::StorageClass>(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<uint32_t>(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<spv::StorageClass>(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<Uint32>(shadowLength, kMaxCarriers * 4);
if (shadowLength > kMaxCarriers * 4) return decline("more than eight clip distances");
}
const Uint32 consumedCount =
m_options.role == Role::Consumer ? std::min<Uint32>(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<spv::StorageClass>(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<int32_t>(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<Instruction*> 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<uint32_t> 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<BasicBlock>(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLabel, 0, killLabel, std::initializer_list<Operand>{}));
killBlock->AddInstruction(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpKill, 0, 0, std::initializer_list<Operand>{}));
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<EmulateClipDistancePass>(Move(options), report));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 <Includes.h>
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<Uint32>& spirv);
static spvtools::Optimizer::PassToken CreateEmulateClipDistancePass(
EmulateClipDistanceOptions options, EmulateClipDistanceReport* report);
private:
EmulateClipDistanceOptions m_options;
EmulateClipDistanceReport* m_report;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 "<this>_<GL atomic-counter binding>"
// (ParseContextBase::growAtomicCounterBlock). That block IS the GL atomic counter