[Fix] (Espryt): redeclare clip/cull distance outputs inside gl_PerVertex for ESSL

This commit is contained in:
2026-10-12 03:55:46 -04:00
parent b4f35cd67d
commit 2a1920adce
4 changed files with 298 additions and 0 deletions
@@ -12079,6 +12079,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
? PointSizeExtensionName(g_GLESCapabilities.TessellationPointSizeSupport, /*tessellation=*/true)
: nullptr;
source = RequestPointSizeExtension(Move(source), passthroughPointSizeExtension);
// Same for gl_ClipDistance/gl_CullDistance mirrored out of a vertex stage that
// writes them: the neighbour could only compile with the extension, so it exists.
source = RequestClipCullDistanceExtension(Move(source));
const GLuint backendShaderId = g_GLESFuncs.glCreateShader(GL_TESS_CONTROL_SHADER);
if (backendShaderId == 0) {
@@ -12636,6 +12639,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
stageAtomicCounterGlBindings.begin(),
stageAtomicCounterGlBindings.end());
// SPIRV-Cross sizes written clip/cull distances with a standalone
// `out float gl_ClipDistance[N];`, which an ES compiler may refuse as a reserved
// name and so lose the whole program. Redeclared as gl_PerVertex members instead.
// BEFORE the gl_PointSize extension probe below (the block can name it) and
// before vertexStageEssl is captured, so a synthesized pass-through control
// stage mirrors the block this produces.
source = RedeclareClipCullDistancesInPerVertexBlock(std::move(source), glShaderType);
// Position in the chain is arbitrary: this is the only header-level rewrite, it
// edits #extension directives and never the body, and the replacement is the
// same length and stays an #extension line - so it commutes with every pass
+110
View File
@@ -956,6 +956,116 @@ namespace MobileGL::MG_Backend::DirectGLES {
return std::nullopt;
}
String RedeclareClipCullDistancesInPerVertexBlock(String glslCode, const GLenum shaderType) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (shaderType != GL_VERTEX_SHADER && shaderType != GL_TESS_EVALUATION_SHADER &&
shaderType != GL_GEOMETRY_SHADER) {
return glslCode;
}
if (glslCode.find("gl_ClipDistance[") == String::npos &&
glslCode.find("gl_CullDistance[") == String::npos) {
return glslCode;
}
// Whole lines only: the declarations SPIRV-Cross prints are one statement per line,
// and anchoring on the line keeps a use of the array in the body from ever matching.
static const std::regex standaloneRegex(
R"(^\s*out\s+(?:highp\s+)?float\s+(gl_ClipDistance|gl_CullDistance)\s*\[\s*([0-9]+)\s*\]\s*;\s*$)");
static const std::regex invariantPositionRegex(R"(^\s*invariant\s+gl_Position\s*;\s*$)");
Vector<String> lines;
SizeT lineStart = 0;
while (lineStart <= glslCode.size()) {
const SizeT lineEnd = glslCode.find('\n', lineStart);
if (lineEnd == String::npos) {
lines.push_back(glslCode.substr(lineStart));
break;
}
lines.push_back(glslCode.substr(lineStart, lineEnd - lineStart));
lineStart = lineEnd + 1;
}
String clipSize;
String cullSize;
SizeT insertAt = lines.size();
SizeT invariantLine = lines.size();
for (SizeT i = 0; i < lines.size(); ++i) {
std::smatch match;
if (std::regex_match(lines[i], match, standaloneRegex)) {
(match[1] == "gl_ClipDistance" ? clipSize : cullSize) = match[2].str();
if (insertAt == lines.size()) insertAt = i;
} else if (invariantLine == lines.size() && std::regex_match(lines[i], invariantPositionRegex)) {
invariantLine = i;
}
}
if (insertAt == lines.size()) {
return glslCode;
}
// A WRITE of gl_PointSize is a bare identifier; a read of the previous stage's value
// is a member access on gl_in, which is not this block's business.
const auto isIdentifierChar = [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
};
Bool writesPointSize = false;
static constexpr const char* kPointSize = "gl_PointSize";
const SizeT pointSizeLength = std::strlen(kPointSize);
for (SizeT pos = glslCode.find(kPointSize); pos != String::npos && !writesPointSize;
pos = glslCode.find(kPointSize, pos + 1)) {
if (pos + pointSizeLength < glslCode.size() && isIdentifierChar(glslCode[pos + pointSizeLength])) {
continue;
}
SizeT before = pos;
while (before > 0 && std::isspace(static_cast<unsigned char>(glslCode[before - 1]))) --before;
if (before > 0 && (glslCode[before - 1] == '.' || isIdentifierChar(glslCode[before - 1]))) {
continue;
}
writesPointSize = true;
}
const Bool invariantPosition = invariantLine != lines.size();
String block = "out gl_PerVertex\n{\n";
block += invariantPosition ? " invariant highp vec4 gl_Position;\n" : " highp vec4 gl_Position;\n";
if (writesPointSize) block += " highp float gl_PointSize;\n";
if (!clipSize.empty()) block += " highp float gl_ClipDistance[" + clipSize + "];\n";
if (!cullSize.empty()) block += " highp float gl_CullDistance[" + cullSize + "];\n";
block += "};";
String result;
result.reserve(glslCode.size() + block.size());
for (SizeT i = 0; i < lines.size(); ++i) {
if (i == insertAt) {
result += block;
} else if (i == invariantLine || std::regex_match(lines[i], standaloneRegex)) {
continue;
} else {
result += lines[i];
}
if (i + 1 < lines.size()) result += '\n';
}
return result;
}
String RequestClipCullDistanceExtension(String glslCode) {
static constexpr const char* kDirective = "#extension GL_EXT_clip_cull_distance : require\n";
if (glslCode.find("GL_EXT_clip_cull_distance") != String::npos ||
(glslCode.find("gl_ClipDistance") == String::npos && glslCode.find("gl_CullDistance") == String::npos)) {
return glslCode;
}
// Right after the #version line, like every other directive this backend adds.
const SizeT versionPos = glslCode.find("#version");
if (versionPos == String::npos) {
return kDirective + glslCode;
}
const SizeT lineEnd = glslCode.find('\n', versionPos);
if (lineEnd == String::npos) {
return glslCode + "\n" + kDirective;
}
glslCode.insert(lineEnd + 1, kDirective);
return glslCode;
}
String BuildPassthroughTessControlEssl(const Uint esslVersion, const Uint patchVertices,
const String& inPerVertexMembers,
const String& outPerVertexMembers,
+19
View File
@@ -386,6 +386,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
// that redeclares a different shape than its neighbours is an ES link error against a
// program that has no other problem.
std::optional<String> ExtractPerVertexBlockMembers(const String& essl, Bool input);
// Moves the clip/cull distance OUTPUTS of a vertex, tessellation evaluation or geometry
// stage into a redeclared `out gl_PerVertex { ... };` block. SPIRV-Cross sizes the two
// arrays with a standalone `out float gl_ClipDistance[N];` - legal desktop GLSL, but an
// ES compiler may refuse it outright ("reserved built-in name"), and leaving the
// declaration out instead compiles but fails the link on an array-size mismatch against
// the fragment stage. A member of the redeclared block is the spelling ESSL accepts.
// gl_Position is always a member (every pre-rasterization stage has it), gl_PointSize
// only when the stage writes it, so the block never names a built-in the stage would
// otherwise not need. A stage-level `invariant gl_Position;` moves onto the member,
// because the bare re-qualification of a block member is not something ESSL allows.
// Fragment inputs and the control stage (whose block SPIRV-Cross already emits) are
// left alone; a stage without the standalone declarations comes back unchanged.
String RedeclareClipCullDistancesInPerVertexBlock(String glslCode, GLenum shaderType);
// Adds `#extension GL_EXT_clip_cull_distance : require` when `glslCode` names
// gl_ClipDistance or gl_CullDistance and does not request it yet. For the synthesized
// pass-through control stage, whose gl_in/gl_out member lists are mirrored from stages
// that may carry the clip/cull arrays: SPIRV-Cross requests the extension for the stages
// it emits, but nothing does for a stage built by hand.
String RequestClipCullDistanceExtension(String glslCode);
// The pass-through tessellation control stage GL 4.6 core 11.2.2 describes: "the input
// patch is passed through unmodified", the output patch has PATCH_VERTICES vertices, and
// the levels come from the PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL state.
@@ -14,6 +14,11 @@
#include <gtest/gtest.h>
#include <MG_Backend/DirectGLES/Utils.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include "Init.h"
#include <limits>
@@ -28,10 +33,12 @@ using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_SPLIT_READ_ALIAS_PREFI
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITEONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RedeclareClipCullDistancesInPerVertexBlock;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::PointSizeExtensionName;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestPointSizeExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestClipCullDistanceExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::SplitReadWriteImageUniforms;
@@ -1517,3 +1524,154 @@ void main() {}
)";
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, true).has_value()) << essl;
}
// The shape SPIRV-Cross prints for a vertex stage writing clip and cull distances: standalone,
// sized, `out` redeclarations an ES compiler refuses as reserved names.
TEST(RedeclareClipCullDistancesTest, MovesTheStandaloneArraysIntoTheOutputBlock) {
const String essl = R"(#version 320 es
#extension GL_EXT_clip_cull_distance : require
out float gl_ClipDistance[4];
out float gl_CullDistance[1];
void main()
{
gl_Position = vec4(1.0);
gl_ClipDistance[0] = 1.0;
gl_CullDistance[0] = 1.0;
}
)";
const String out = RedeclareClipCullDistancesInPerVertexBlock(essl, GL_VERTEX_SHADER);
EXPECT_FALSE(Contains(out, "out float gl_ClipDistance")) << out;
EXPECT_FALSE(Contains(out, "out float gl_CullDistance")) << out;
EXPECT_TRUE(Contains(out, "out gl_PerVertex\n{\n highp vec4 gl_Position;\n"
" highp float gl_ClipDistance[4];\n"
" highp float gl_CullDistance[1];\n};"))
<< out;
EXPECT_FALSE(Contains(out, "gl_PointSize")) << out;
// The body is untouched.
EXPECT_TRUE(Contains(out, "gl_ClipDistance[0] = 1.0;")) << out;
EXPECT_EQ(CountOf(out, "gl_PerVertex"), 1u) << out;
// What a synthesized pass-through control stage would mirror from this stage.
const auto members = ExtractPerVertexBlockMembers(out, /*input=*/false);
ASSERT_TRUE(members.has_value()) << out;
EXPECT_TRUE(Contains(*members, "highp float gl_ClipDistance[4];")) << *members;
}
TEST(RedeclareClipCullDistancesTest, KeepsAWrittenPointSizeAndFoldsInvariance) {
const String essl = R"(#version 320 es
#extension GL_EXT_clip_cull_distance : require
layout(points) in;
layout(points, max_vertices = 1) out;
out float gl_ClipDistance[2];
invariant gl_Position;
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
gl_ClipDistance[1] = 0.5;
EmitVertex();
}
)";
const String out = RedeclareClipCullDistancesInPerVertexBlock(essl, GL_GEOMETRY_SHADER);
EXPECT_TRUE(Contains(out, " invariant highp vec4 gl_Position;\n highp float gl_PointSize;\n"
" highp float gl_ClipDistance[2];\n};"))
<< out;
EXPECT_FALSE(Contains(out, "invariant gl_Position;")) << out;
EXPECT_FALSE(Contains(out, "out float gl_ClipDistance")) << out;
}
// Only a READ of the previous stage's point size: not this stage's output, so not a member.
TEST(RedeclareClipCullDistancesTest, AnInputPointSizeReadIsNotAMember) {
const String essl = R"(#version 320 es
layout(triangles) in;
out float gl_ClipDistance[1];
void main()
{
gl_Position = gl_in[0].gl_Position * gl_in[0].gl_PointSize;
gl_ClipDistance[0] = 1.0;
}
)";
const String out = RedeclareClipCullDistancesInPerVertexBlock(essl, GL_TESS_EVALUATION_SHADER);
EXPECT_TRUE(Contains(out, "out gl_PerVertex")) << out;
EXPECT_FALSE(Contains(out, "highp float gl_PointSize;")) << out;
}
TEST(RedeclareClipCullDistancesTest, LeavesFragmentInputsControlStagesAndPlainShadersAlone) {
const String fragment = R"(#version 320 es
in float gl_ClipDistance[4];
layout(location = 0) out highp vec4 c;
void main() { c = vec4(gl_ClipDistance[0]); }
)";
EXPECT_EQ(RedeclareClipCullDistancesInPerVertexBlock(fragment, GL_FRAGMENT_SHADER), fragment);
const String control = R"(#version 320 es
layout(vertices = 3) out;
out gl_PerVertex
{
vec4 gl_Position;
float gl_ClipDistance[1];
} gl_out[3];
void main() { gl_out[gl_InvocationID].gl_ClipDistance[0] = 1.0; }
)";
EXPECT_EQ(RedeclareClipCullDistancesInPerVertexBlock(control, GL_TESS_CONTROL_SHADER), control);
const String plain = "#version 320 es\nvoid main()\n{\n gl_Position = vec4(0.0);\n}\n";
EXPECT_EQ(RedeclareClipCullDistancesInPerVertexBlock(plain, GL_VERTEX_SHADER), plain);
}
TEST(RequestClipCullDistanceExtensionTest, AddsTheDirectiveOnlyWhenTheArraysAreNamed) {
const String mirrored = "#version 320 es\nin gl_PerVertex { highp vec4 gl_Position; highp float "
"gl_ClipDistance[2]; } gl_in[gl_MaxPatchVertices];\nvoid main() {}\n";
const String out = RequestClipCullDistanceExtension(mirrored);
EXPECT_EQ(out.find("#version 320 es\n#extension GL_EXT_clip_cull_distance : require\n"), 0u) << out;
EXPECT_EQ(RequestClipCullDistanceExtension(out), out);
const String plain = "#version 320 es\nvoid main() {}\n";
EXPECT_EQ(RequestClipCullDistanceExtension(plain), plain);
}
// End to end against what SPIRV-Cross really prints, so the fixtures above cannot drift from it.
TEST(RedeclareClipCullDistancesTest, RewritesRealSpirvCrossOutput) {
using namespace MG_Util::ShaderTranspiler;
MobileGL::Initialize();
const char* vertexSource = R"(#version 330 core
out float gl_ClipDistance[3];
void main() {
gl_Position = vec4(float(gl_VertexID), 0.0, 0.0, 1.0);
for (int i = 0; i < 3; ++i) gl_ClipDistance[i] = float(i) - 1.0;
}
)";
const char* fragmentSource = R"(#version 330 core
out vec4 color;
void main() { color = vec4(1.0); }
)";
Vector<SharedPtr<glslang::TShader>> shaders;
const Vector<Pair<GLenum, const char*>> stages{{GL_VERTEX_SHADER, vertexSource},
{GL_FRAGMENT_SHADER, fragmentSource}};
for (const auto& [stage, text] : stages) {
ShaderAttrib attrib{.shaderType = stage, .sourceStr = text};
auto compiled = ShaderCompiler::CompileShader(attrib);
ASSERT_TRUE(compiled) << compiled.error().log;
shaders.push_back(compiled.value());
}
ProgramAttrib programAttrib{.shaders = shaders};
auto program = ShaderCompiler::LinkProgram(programAttrib);
ASSERT_TRUE(program) << program.error().log;
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_VERTEX_SHADER, GL_FRAGMENT_SHADER},
.program = *program.value()};
auto modules = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
ASSERT_TRUE(modules) << modules.error().log;
ASSERT_EQ(modules.value().size(), 2u);
SpvcSession session(modules.value()[0], SessionUsageBit::Transpile);
auto essl = ShaderCompiler::DecompileShader(session);
ASSERT_TRUE(essl) << essl.error().log;
// The shape the pass exists for really is what SPIRV-Cross prints.
ASSERT_TRUE(Contains(essl.value(), "out float gl_ClipDistance[3];")) << essl.value();
const String out = RedeclareClipCullDistancesInPerVertexBlock(essl.value(), GL_VERTEX_SHADER);
EXPECT_FALSE(Contains(out, "out float gl_ClipDistance")) << out;
EXPECT_TRUE(Contains(out, " highp float gl_ClipDistance[3];\n};")) << out;
EXPECT_TRUE(Contains(out, "GL_EXT_clip_cull_distance")) << out;
}