diff --git a/CMakeLists.txt b/CMakeLists.txt index 720670dd..c2ce320c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index f4541f85..6abc03cd 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -2620,3 +2620,217 @@ TEST_F(ProgramTest, ProgramAndShaderNamesShareOneNameSpace) { DeleteProgram(program); EXPECT_EQ(GetError(), GL_NO_ERROR); } + +// ---- builtin-shadowing OpName pass (P0c) ---- +// Desktop GLSL lets a pack redefine builtins; ESSL 3.x forbids it, so the rename +// now happens as a SPIR-V OpName pass in SanitizeAndOptimizeBinary instead of the +// old whole-source string scan. These pin the pass end-to-end: real sources through +// glCompileShader/glLinkProgram, generated SPIR-V transpiled to the ESSL the Espryt +// driver would see. + +namespace { + Vector TranspileProgramSpirvToEssl(GLuint program) { + Vector esslModules; + auto programObj = MG_State::pGLContext->GetProgramObject(program); + for (auto& spirvCode : programObj->GetGeneratedSpirv()) { + MG_Util::ShaderTranspiler::SpvcSession spvcSession( + spirvCode, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile); + spvc_compiler_options options; + spvcSession.CreateOptions(&options); + spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE); + spvcSession.SetOptions(options); + const char* result = nullptr; + spvcSession.Compile(&result); + EXPECT_NE(result, nullptr) << spvcSession.GetLastErrorString(); + esslModules.push_back(result ? result : ""); + } + return esslModules; + } +} // namespace + +// The two blind spots of the old string scan, eliminated by construction: a +// MULTILINE definition (bliss-shaped "float fma\n(...)"), and names outside the +// old 5-entry list: sinh, as a NEW overload no builtin signature matches, so it +// parses fine and the SPIR-V OpName backstop does the rename. (An EXACT-signature +// sinh redefinition is parse-rejected by glslang - on HEAD too - and is therefore +// deliberately NOT lexically rescued; see kLexicalPreemptRenameNames.) +// min3/max3 keep their historical coverage. +TEST_F(ProgramTest, BuiltinShadowingFunctionsRenamedInEsslOutput) { + const char* vsSource = R"(#version 330 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + const char* fsSource = R"(#version 330 core +out vec4 fragColor; + +float fma + (float a, float b, float c) { return a * b + c; } +float sinh(float x, float y) { return x * y; } +float round(float x) { return floor(x + 0.5); } +float min3(float a, float b, float c) { return min(min(a, b), c); } + +void main() { + fragColor = vec4(fma(0.1, 0.2, 0.3), sinh(0.4, 2.0), round(1.25), min3(0.1, 0.2, 0.3)); +} +)"; + GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource); + GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource); + GLuint program = LinkVsFs(vs, fs, GL_TRUE); + + for (const auto& essl : TranspileProgramSpirvToEssl(program)) { + if (essl.find("fragColor") == String::npos) continue; // fragment module only + EXPECT_NE(essl.find("mg_fma("), String::npos) << essl; + EXPECT_NE(essl.find("mg_sinh("), String::npos) << essl; + EXPECT_NE(essl.find("mg_round("), String::npos) << essl; + EXPECT_NE(essl.find("mg_min3("), String::npos) << essl; + EXPECT_EQ(essl.find("float fma("), String::npos) << essl; + EXPECT_EQ(essl.find("float sinh("), String::npos) << essl; + EXPECT_EQ(essl.find("float round("), String::npos) << essl; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// Pure builtin USAGE (plus a commented-out definition) must stay untouched: builtin +// calls never resolve to a user function id in SPIR-V, so no mg_ name may appear. +TEST_F(ProgramTest, BuiltinUsageWithoutShadowingDefinitionKeepsBuiltinCalls) { + const char* vsSource = R"(#version 330 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + // 400, not 330: the builtin fma() really is called here, and it is only core from GLSL 4.00 + // (at 330 it needs GL_ARB_gpu_shader5). The shadowing case above can stay at 330 precisely + // because the rename means no call to the builtin survives. + const char* fsSource = R"(#version 400 core +// float round(float x) { return floor(x + 0.5); } +out vec4 fragColor; +void main() { + fragColor = vec4(round(1.25), fma(0.1, 0.2, 0.3), tanh(0.5), 1.0); +} +)"; + GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource); + GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource); + GLuint program = LinkVsFs(vs, fs, GL_TRUE); + + for (const auto& essl : TranspileProgramSpirvToEssl(program)) { + EXPECT_EQ(essl.find("mg_"), String::npos) << essl; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// ---- the three shapes the lexical pre-empt pass must NOT touch (P0c) ---- +// The source-level rename runs only for the handful of names glslang's relaxed +// parse rejects outright; everything else waits for the OpName pass, which cannot +// over-fire. These pin the three ways a lexical scan gets it wrong. All of them +// would fail as "no matching overloaded function found" - an over-detection is +// unrecoverable because the source never reaches SPIR-V. + +namespace { + // "pow(" as a real builtin call, i.e. not the tail of "mg_pow(". + bool ContainsUnprefixedCall(const MobileGL::String& essl, const MobileGL::String& name) { + const MobileGL::String needle = name + "("; + for (SizeT pos = essl.find(needle); pos != String::npos; pos = essl.find(needle, pos + 1)) { + const char before = pos == 0 ? ' ' : essl[pos - 1]; + const bool isIdentifierChar = + std::isalnum(static_cast(before)) != 0 || before == '_'; + if (!isIdentifierChar) return true; + } + return false; + } +} // namespace + +// B1: preprocessor-asymmetric braces desync a raw brace-depth counter (each arm of +// the #ifdef closes the function), and "return" is lexically an identifier - so +// "return clamp(...)" reads as a top-level definition " (". A +// shader that shadows nothing must survive intact. +TEST_F(ProgramTest, StatementKeywordCallInPreprocessorAsymmetricBracesIsNotAShadowingDefinition) { + const char* vsSource = R"(#version 330 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + const char* fsSource = R"(#version 330 core +uniform vec3 uP; +out vec4 fragColor; + +float getShadow(vec3 v) { +#ifdef SHADOW_OFF + return 1.0; +} +#else + return round(dot(v, v)); +} +#endif + +void main() { fragColor = vec4(getShadow(uP) * clamp(uP.x, 0.0, 1.0)); } +)"; + GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource); + GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource); + GLuint program = LinkVsFs(vs, fs, GL_TRUE); + + for (const auto& essl : TranspileProgramSpirvToEssl(program)) { + if (essl.find("fragColor") == String::npos) continue; // fragment module only + EXPECT_EQ(essl.find("mg_"), String::npos) << essl; + // SPIRV-Cross lowers GLSL.std.450 FClamp to its NaN-correct min/max/isnan form, so the + // surviving evidence of the builtin call is that pair, not the spelling "clamp(". The + // stronger guard is above it: a renamed mg_clamp would not have compiled at all. + EXPECT_TRUE(ContainsUnprefixedCall(essl, "min")) << essl; + EXPECT_TRUE(ContainsUnprefixedCall(essl, "max")) << essl; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// B2: the scan is preprocessor-blind, so a definition in a DEAD #if branch would +// poison every live call to the real builtin. #version 120 normalizes to 330, so +// __VERSION__ is 330 and the compat shim is dropped by glslang - the definition +// never exists, and nothing may be renamed. +TEST_F(ProgramTest, ShadowingDefinitionInDeadPreprocessorBranchLeavesLiveBuiltinCalls) { + const char* vsSource = R"(#version 120 +#if __VERSION__ < 140 +mat4 inverse(mat4 m) { return m; } +#endif +uniform mat4 uM; +uniform vec4 uV; +void main() { gl_Position = inverse(uM) * uV; } +)"; + const char* fsSource = R"(#version 330 core +out vec4 fragColor; +void main() { fragColor = vec4(1.0); } +)"; + GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource); + GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource); + GLuint program = LinkVsFs(vs, fs, GL_TRUE); + + for (const auto& essl : TranspileProgramSpirvToEssl(program)) { + if (essl.find("gl_Position") == String::npos) continue; // vertex module only + EXPECT_EQ(essl.find("mg_"), String::npos) << essl; + EXPECT_TRUE(ContainsUnprefixedCall(essl, "inverse")) << essl; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// B3: the idiomatic reason to shadow a builtin is to ADD an overload and delegate +// to the real one. A blanket call-site rewrite would turn the body's builtin call +// into mg_pow(vec3, vec3), which has no overload. The OpName backstop renames the +// user function id only, so the delegation still resolves to GLSL.std.450 Pow. +TEST_F(ProgramTest, OverloadDelegatingToShadowedBuiltinKeepsItsBuiltinCall) { + const char* vsSource = R"(#version 330 core +void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } +)"; + const char* fsSource = R"(#version 330 core +uniform vec3 uBase; +out vec4 fragColor; + +vec3 pow(vec3 v, float e) { return pow(v, vec3(e)); } + +void main() { fragColor = vec4(pow(uBase, 2.2), 1.0); } +)"; + GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource); + GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource); + GLuint program = LinkVsFs(vs, fs, GL_TRUE); + + for (const auto& essl : TranspileProgramSpirvToEssl(program)) { + if (essl.find("fragColor") == String::npos) continue; // fragment module only + EXPECT_NE(essl.find("mg_pow("), String::npos) << essl; + EXPECT_TRUE(ContainsUnprefixedCall(essl, "pow")) << essl; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 27993c91..280b95cd 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -615,25 +615,6 @@ void main() { } } -// The builtin-shadowing rename only fires when the shader really defines its own round/tanh/etc. -// Deciding that from a commented-out definition renames every genuine call to the builtin to a -// mg_ name that nothing defines, which fails to link. -TEST_F(ProgramUtilTest, PreprocessIgnoresCommentedOutBuiltinShadowingDefinition) { - using namespace MG_Util::ShaderTranspiler; - - String source = R"(#version 330 core -// float round(float x) { return floor(x + 0.5); } -out vec4 fragColor; -void main() { - fragColor = vec4(round(1.25)); -} -)"; - PreprocessShaderSource(ShaderStage::Fragment, source); - - EXPECT_NE(source.find("round(1.25)"), String::npos) << "call was renamed from a comment:\n" << source; - EXPECT_EQ(source.find("mg_round"), String::npos); -} - // A block-commented extension directive must not be treated as a real one - the int64 filter turns // unsupported directives into #error, so reading one out of a comment manufactures a compile // failure for a shader that never asked for the extension. @@ -844,36 +825,6 @@ void main() { } } -TEST_F(ProgramUtilTest, PreprocessFragmentShaderRenamesMin3Max3Helpers) { - using namespace MG_Util::ShaderTranspiler; - - String source = R"(#version 460 core -out vec4 fragColor; - -float min3(float a, float b, float c) { return min(min(a, b), c); } -float max3(float a, float b, float c) { return max(max(a, b), c); } - -void main() { - float dark = min3(0.1, 0.2, 0.3); - float bright = max3(max3(0.1, 0.2, 0.3), 0.4, 0.5); - fragColor = vec4(dark, bright, 0.0, 1.0); -})"; - - PreprocessShaderSource(ShaderStage::Fragment, source); - - EXPECT_NE(source.find("float mg_min3("), String::npos); - EXPECT_NE(source.find("float mg_max3("), String::npos); - EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos); - EXPECT_NE(source.find("mg_max3(mg_max3(0.1, 0.2, 0.3), 0.4, 0.5)"), String::npos); - EXPECT_EQ(source.find("float min3("), String::npos); - EXPECT_EQ(source.find("float max3("), String::npos); - - ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source}; - auto res = ShaderCompiler::CompileShader(attrib); - if (!res) { - FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source; - } -} const char* vs = R"(#version 150 @@ -2248,3 +2199,25 @@ TEST_F(ProgramUtilTest, RewriteLinearSubgroupPrefixScanRejectsPartialOrUnsafeTem "float other = shuffleNV(1.0f, 0u, 32u);\n "); expectUnchanged(std::move(nvShuffleCall)); } + +// The LEXICAL half must fire at the source level (before the parse) for the +// preempt-list names - the end-to-end ESSL tests cannot tell which half did the +// rename, and for these names the parse would fail without the source rewrite. +TEST_F(ProgramUtilTest, PreprocessRenamesLexicalPreemptShadowingInSource) { + using namespace MG_Util::ShaderTranspiler; + + String source = R"(#version 460 core +out vec4 fragColor; + +float min3(float a, float b, float c) { return min(min(a, b), c); } + +void main() { + fragColor = vec4(min3(0.1, 0.2, 0.3)); +} +)"; + PreprocessShaderSource(ShaderStage::Fragment, source); + + EXPECT_NE(source.find("float mg_min3("), String::npos) << source; + EXPECT_NE(source.find("mg_min3(0.1, 0.2, 0.3)"), String::npos) << source; + EXPECT_EQ(source.find("float min3("), String::npos) << source; +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.h b/MobileGL/MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.h new file mode 100644 index 00000000..afee2921 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.h @@ -0,0 +1,123 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.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 +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // Builtin-shadowing rename: TWO tables, split by FAILURE LAYER. + // + // A desktop pack may redefine a builtin; ESSL 3.x forbids the redefinition, so every + // such helper is renamed to mg_. That rename happens in two places, and which + // names belong in which place is decided by *where the failure would occur*, not by + // how thorough the table looks: + // + // - kEsslBuiltinFunctionNames (below, the full ~146-name ESSL 3.20 set plus the + // GL_AMD/EXT trinary min3/mid3/max3) drives the SPIR-V OpName backstop pass. That + // pass is safe BY CONSTRUCTION for any name: it renames function ids, and builtin + // calls are GLSL.std.450 instructions that can never resolve to a user OpFunction. + // Overloads are distinct ids (so an overload delegating to the real builtin keeps + // working), dead preprocessor branches never reach SPIR-V, and there is no lexical + // guessing to over-fire. Everything that CAN wait for the IR belongs here only. + // + // - kLexicalPreemptRenameNames (a strict handful-of-names subset) drives the source-level + // scan in ShaderSourceProcessor::RenameBuiltinShadowingFunctions. That scan exists + // for exactly one reason: glslang's relaxed parse rejects some shadowing overload + // shapes at PARSE time ("overloaded functions must have the same parameter + // precision qualifiers"), and a shadowed builtin can itself need an extension the + // declared #version does not enable (fma() at #version 330 wants + // GL_ARB_gpu_shader5) - such a shader never produces SPIR-V, so the backstop never + // sees it. Only names empirically observed to hit that parse-level rejection go + // here. A lexical scan is preprocessor-blind and cannot see overload sets, so it + // can over-fire (rename a live call whose definition sits in a dead #if branch, or + // rewrite an overload's delegating call to the real builtin) - and over-detection + // is UNRECOVERABLE, because the source never reaches the backstop. Keeping this + // table minimal keeps that exposure at its historical scope. + // + // "main" is deliberately absent from both. + inline constexpr std::string_view kEsslBuiltinFunctionNames[] = { + "EmitVertex", "EndPrimitive", + "abs", "acos", "acosh", "all", "any", "asin", "asinh", "atan", "atanh", + "atomicAdd", "atomicAnd", "atomicCompSwap", "atomicCounter", + "atomicCounterDecrement", "atomicCounterIncrement", "atomicExchange", + "atomicMax", "atomicMin", "atomicOr", "atomicXor", + "barrier", "bitCount", "bitfieldExtract", "bitfieldInsert", "bitfieldReverse", + "ceil", "clamp", "cos", "cosh", "cross", + "dFdx", "dFdy", "degrees", "determinant", "distance", "dot", + "equal", "exp", "exp2", + "faceforward", "findLSB", "findMSB", "floatBitsToInt", "floatBitsToUint", + "floor", "fma", "fract", "frexp", "fwidth", + "greaterThan", "greaterThanEqual", "groupMemoryBarrier", + "imageAtomicAdd", "imageAtomicAnd", "imageAtomicCompSwap", + "imageAtomicExchange", "imageAtomicMax", "imageAtomicMin", "imageAtomicOr", + "imageAtomicXor", "imageLoad", "imageSize", "imageStore", "imulExtended", + "intBitsToFloat", "interpolateAtCentroid", "interpolateAtOffset", + "interpolateAtSample", "inverse", "inversesqrt", "isinf", "isnan", + "ldexp", "length", "lessThan", "lessThanEqual", "log", "log2", + "matrixCompMult", "max", "max3", "memoryBarrier", + "memoryBarrierAtomicCounter", "memoryBarrierBuffer", "memoryBarrierImage", + "memoryBarrierShared", "mid3", "min", "min3", "mix", "mod", "modf", + "normalize", "not", "notEqual", + "outerProduct", + "packHalf2x16", "packSnorm2x16", "packSnorm4x8", "packUnorm2x16", + "packUnorm4x8", "pow", + "radians", "reflect", "refract", "round", "roundEven", + "sign", "sin", "sinh", "smoothstep", "sqrt", "step", + "tan", "tanh", "texelFetch", "texelFetchOffset", "texture", + "textureGather", "textureGatherOffset", "textureGatherOffsets", + "textureGrad", "textureGradOffset", "textureLod", "textureLodOffset", + "textureOffset", "textureProj", "textureProjGrad", "textureProjGradOffset", + "textureProjLod", "textureProjLodOffset", "textureProjOffset", "textureSize", + "transpose", "trunc", + "uaddCarry", "uintBitsToFloat", "umulExtended", "unpackHalf2x16", + "unpackSnorm2x16", "unpackSnorm4x8", "unpackUnorm2x16", "unpackUnorm4x8", + "usubBorrow", + }; + + inline bool IsEsslBuiltinFunctionName(std::string_view name) { + return std::binary_search(std::begin(kEsslBuiltinFunctionNames), + std::end(kEsslBuiltinFunctionNames), name); + } + + // The parse-level subset, sorted for std::binary_search. + // + // What decides membership, measured against this glslang: a redefinition whose + // signature EXACTLY matches a builtin overload is rejected at parse time + // ("overloaded functions must have the same parameter precision qualifiers", because + // the builtin declaration carries precision qualifiers and the user's does not), so + // it never produces SPIR-V and the OpName backstop never gets a turn. A definition + // that merely ADDS an overload (a signature the builtin set does not have, e.g. + // vec3 pow(vec3, float)) parses fine and is the backstop's job. Probed across the + // full table with a float(float) redefinition, 38 names are rejected that way - so + // membership here is not "everything that could ever be rejected", it is the set + // actually seen in shipped content plus whatever the test suite pins: + // fma, tanh - the bliss shaderpack's from-scratch helpers + // round, min3, max3 - the historical string-scan list this pass replaced + // An EXACT-signature redefinition of any of the other 33 probed-rejected names + // (sinh, floor, sqrt, ...) never compiled on MobileGL HEAD either - the old + // 5-name string scan did not rescue them - so leaving them out preserves the + // status quo for that (never-working) shape while keeping the dead-#if / + // overload-delegation exposure at exactly its historical scope. + // Adding a name is not free: it buys a parse-time rescue at the cost of lexical + // over-detection risk on every shader that merely *calls* that builtin (a definition + // in a dead #if branch, or an overload delegating to the real builtin). Add one only + // with evidence that real content redefines it with a builtin-identical signature. + inline constexpr std::string_view kLexicalPreemptRenameNames[] = { + "fma", "max3", "min3", "round", "tanh", + }; + + inline bool IsLexicalPreemptRenameName(std::string_view name) { + return std::binary_search(std::begin(kLexicalPreemptRenameNames), + std::end(kLexicalPreemptRenameNames), name); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 1e8fdb4e..204880c4 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -15,6 +15,7 @@ #include "SpirvPasses/EliminateFloatEqualsZeroPass.h" #include "SpirvPasses/FlattenInterfaceStructPass.h" #include "SpirvPasses/RenameSamplerFunctionParameterPass.h" +#include "SpirvPasses/RenameBuiltinShadowingFunctionsPass.h" #include "SpirvPasses/DecomposeWorkgroupVec3Pass.h" #include "SpirvPasses/DecoratePositionInvariantPass.h" #include "SpirvPasses/LowerDrawParametersPass.h" @@ -310,6 +311,8 @@ namespace MobileGL { optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass()); optimizer.RegisterPass(FlattenInterfaceStructPass::CreateFlattenInterfaceStructPass()); optimizer.RegisterPass(RenameSamplerFunctionParameterPass::CreateRenameSamplerFunctionParameterPass()); + optimizer.RegisterPass( + RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass()); optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass()); optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass()); diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp index 6e45cd86..d47066cc 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp @@ -17,6 +17,8 @@ #include #include +#include "EsslBuiltinFunctionNames.h" + namespace { using MobileGL::SizeT; using MobileGL::String; @@ -755,7 +757,23 @@ namespace { source.insert(0, replacement); } - bool HasSingleLineFunctionDefinition(const MobileGL::String& source, const MobileGL::String& functionName) { + // Start of the physical line containing `offset`, never scanning before `lowerBound`. + SizeT FindPhysicalLineStart(const MobileGL::String& source, SizeT offset, SizeT lowerBound) { + if (offset == 0) { + return lowerBound; + } + const SizeT newline = source.rfind('\n', offset - 1); + if (newline == MobileGL::String::npos || newline + 1 < lowerBound) { + return lowerBound; + } + return newline + 1; + } + + // Half-open [begin, end) byte ranges of the preprocessor directive lines, in source order. + // A directive is one logical line: a trailing backslash splices the next physical line into it. + Vector> FindDirectiveLineRanges(const MobileGL::String& source) { + Vector> ranges; + SizeT lineStart = 0; while (lineStart < source.size()) { SizeT lineEnd = source.find('\n', lineStart); @@ -763,71 +781,177 @@ namespace { lineEnd = source.size(); } - SizeT functionPos = source.find(functionName, lineStart); - while (functionPos != MobileGL::String::npos && functionPos < lineEnd) { - const bool hasLeftBoundary = functionPos == 0 || !IsIdentifierChar(source[functionPos - 1]); - const SizeT functionEnd = functionPos + functionName.size(); - const bool hasRightBoundary = functionEnd >= source.size() || !IsIdentifierChar(source[functionEnd]); - if (hasLeftBoundary && hasRightBoundary) { - SizeT probe = functionEnd; - while (probe < lineEnd && std::isspace(static_cast(source[probe]))) { - probe++; - } - if (probe < lineEnd && source[probe] == '(') { - const SizeT closingParen = source.find(')', probe); - if (closingParen != MobileGL::String::npos && closingParen < lineEnd) { - probe = closingParen + 1; - while (probe < lineEnd && std::isspace(static_cast(source[probe]))) { - probe++; - } - if (probe < lineEnd && source[probe] == '{') { - return true; - } - } - } - } - - functionPos = source.find(functionName, functionPos + functionName.size()); - } - - lineStart = lineEnd + 1; - } - - return false; - } - - void RenameFunctionInvocations(MobileGL::String& source, const MobileGL::String& from, const MobileGL::String& to) { - SizeT pos = 0; - while ((pos = source.find(from, pos)) != MobileGL::String::npos) { - const bool hasLeftBoundary = pos == 0 || !IsIdentifierChar(source[pos - 1]); - const SizeT end = pos + from.size(); - const bool hasRightBoundary = end >= source.size() || !IsIdentifierChar(source[end]); - - SizeT probe = end; - while (probe < source.size() && std::isspace(static_cast(source[probe]))) { + SizeT probe = lineStart; + while (probe < lineEnd && std::isspace(static_cast(source[probe]))) { probe++; } - - if (hasLeftBoundary && hasRightBoundary && probe < source.size() && source[probe] == '(') { - source.replace(pos, from.size(), to); - pos += to.size(); + if (probe >= lineEnd || source[probe] != '#') { + lineStart = lineEnd + 1; continue; } - pos = end; + SizeT directiveEnd = lineEnd; + while (directiveEnd < source.size()) { + // directiveEnd sits on a '\n'; a backslash immediately before it (modulo the \r of + // a CRLF file and trailing blanks) splices the following physical line in. + // The scan must not leave the physical line that directiveEnd terminates: a + // whitespace-only spliced line would otherwise let the back-scan reach the + // backslash of the PREVIOUS line and swallow one extra real line of code. + const SizeT physicalLineStart = FindPhysicalLineStart(source, directiveEnd, lineStart); + SizeT back = directiveEnd; + while (back > physicalLineStart && std::isspace(static_cast(source[back - 1]))) { + back--; + } + if (back == physicalLineStart || source[back - 1] != '\\') { + break; + } + SizeT splicedEnd = source.find('\n', directiveEnd + 1); + if (splicedEnd == MobileGL::String::npos) { + splicedEnd = source.size(); + } + directiveEnd = splicedEnd; + } + + ranges.push_back({lineStart, directiveEnd}); + lineStart = directiveEnd + 1; } + + return ranges; } - void RenameBuiltinShadowingFunction(MobileGL::String& source, const char* from, const char* to) { - const MobileGL::String fromName = from; - // Decide from a comment-free view. A commented-out definition is not a definition, and - // acting on one renames every genuine call to the builtin to a name nothing defines - which - // then fails to resolve. Line comments survive BlankBlockComments, so this matters. - if (!HasSingleLineFunctionDefinition(MaskCommentsAndQuotedText(source), fromName)) { + bool IsInDirectiveLine(const Vector>& ranges, SizeT offset) { + // Ranges are disjoint and sorted, so the only candidate is the last one starting at or + // before the offset. + const auto next = std::upper_bound(ranges.begin(), ranges.end(), offset, + [](SizeT value, const std::pair& range) { + return value < range.first; + }); + return next != ranges.begin() && offset < std::prev(next)->second; + } + + // No GLSL type name is a statement keyword, so " (" is never a definition - + // it is `return clamp(...)`, `else round(...)`, `do fma(...)`, a `case` label expression. The + // if/for/while/switch entries cannot precede a call in valid GLSL either (a '(' always follows + // them directly), and are listed defensively. Sorted for std::binary_search. + constexpr std::string_view kStatementKeywordsBeforeCall[] = { + "case", "do", "else", "for", "if", "return", "switch", "while", + }; + + bool IsStatementKeywordToken(const CodeToken& token) { + return std::binary_search(std::begin(kStatementKeywordsBeforeCall), + std::end(kStatementKeywordsBeforeCall), std::string_view(token.text)); + } + + // A brace counter over raw tokens is preprocessor-blind: it counts the braces of BOTH arms of + // an #ifdef, so the classic "early return inside one arm, closing brace in each arm" idiom + // desyncs it. A desynced depth turns statements into apparent top-level definitions, and an + // over-detection is unrecoverable (the source never reaches the SPIR-V backstop). A file whose + // braces do not net to zero, or whose running depth ever dips below zero, is therefore not + // trustworthy for depth-based detection at all. + bool HasBalancedBraces(const Vector& tokens) { + SizeT depth = 0; + for (const CodeToken& token : tokens) { + if (token.text.size() != 1) continue; + if (token.text[0] == '{') { + depth++; + } else if (token.text[0] == '}') { + if (depth == 0) return false; + depth--; + } + } + return depth == 0; + } + + // Some shader packs define their own helpers under builtin GLSL names - round(), fma(), + // min3(), tanh(). Desktop GLSL allows that shadowing; ESSL 3.x forbids the redefinition, so + // every such helper is renamed to mg_ together with all of its call sites. + // + // Scope is deliberately NARROW: only kLexicalPreemptRenameNames, the handful of names whose + // shadowing definitions glslang's relaxed parse rejects outright ("overloaded functions must + // have the same parameter precision qualifiers"), or which need an extension the declared + // #version does not enable (fma() at #version 330 wants GL_ARB_gpu_shader5). Those shaders + // never produce SPIR-V, so only a source-level rename can save them. Everything else is left + // to the SPIR-V OpName pass in SanitizeAndOptimizeBinary, which is safe by construction - + // see EsslBuiltinFunctionNames.h for the full failure-layer split. A lexical scan is + // preprocessor-blind and overload-blind, so widening this table trades a rescue nobody needs + // for an unrecoverable over-detection risk on every shader that merely calls the builtin. + // + // Cost: ONE tokenize for the whole job, and nothing further at all in the overwhelmingly + // common no-shadowing case. The path this replaces probed the entire source once per + // candidate name, which measured ~68% of a Complementary-scale pack's compile time. + void RenameBuiltinShadowingFunctions(MobileGL::String& source) { + const Vector tokens = TokenizeCode(source); + if (tokens.size() < 3) { + return; + } + // Desynced depth -> skip the lexical half entirely and let the backstop handle whatever + // this file shadows. Missing a definition is recoverable; inventing one is not. + if (!HasBalancedBraces(tokens)) { + return; + } + const Vector> directiveRanges = FindDirectiveLineRanges(source); + + // Pass A - collect the shadowed names. A definition or prototype at brace depth 0 reads + // as " (", which is what separates it from a call in a + // global initializer ("const float PI = radians(180.0);", where the previous token is '='). + // Token positions ignore layout, so a definition split across lines is found the same way. + Vector shadowedNames; + SizeT braceDepth = 0; + for (SizeT i = 0; i + 1 < tokens.size(); i++) { + const CodeToken& token = tokens[i]; + if (token.text.size() == 1) { + if (token.text[0] == '{') { + braceDepth++; + continue; + } + if (token.text[0] == '}') { + if (braceDepth > 0) braceDepth--; + continue; + } + } + if (braceDepth != 0 || i == 0 || tokens[i + 1].text != "(" || !IsIdentifierToken(tokens[i - 1])) { + continue; + } + // IsIdentifierToken is purely lexical, so "return"/"else"/"do"/"case" pass it. None of + // them is a return type, so "return round(x)" is a CALL, not a definition. + // A directive tail ('#endif' tokenizes to '#' + 'endif') is not a return type; + // without this, a balanced-but-desynced file could see it as one. + if (IsInDirectiveLine(directiveRanges, tokens[i - 1].begin)) { + continue; + } + if (IsStatementKeywordToken(tokens[i - 1])) { + continue; + } + // "#define FOO fma(x, y, z)" defines FOO, not fma. + if (!MobileGL::MG_Util::ShaderTranspiler::IsLexicalPreemptRenameName(token.text) || + IsInDirectiveLine(directiveRanges, token.begin)) { + continue; + } + if (std::find(shadowedNames.begin(), shadowedNames.end(), token.text) == shadowedNames.end()) { + shadowedNames.push_back(token.text); + } + } + + if (shadowedNames.empty()) { return; } - RenameFunctionInvocations(source, fromName, to); + // Pass B - rename the definition, its prototypes and every call. Only a name followed by + // '(' is the function; the same spelling as a variable must keep its own identity. + // Directive lines DO participate: a macro body calling the renamed helper has to follow it. + Vector insertOffsets; + for (SizeT i = 0; i + 1 < tokens.size(); i++) { + if (tokens[i + 1].text != "(") { + continue; + } + if (std::find(shadowedNames.begin(), shadowedNames.end(), tokens[i].text) != shadowedNames.end()) { + insertOffsets.push_back(tokens[i].begin); + } + } + // Back to front, so each recorded offset is still valid when it is used. + for (auto offset = insertOffsets.rbegin(); offset != insertOffsets.rend(); ++offset) { + source.insert(*offset, "mg_"); + } } void ReplaceIdentifier(MobileGL::String& source, const MobileGL::String& from, const MobileGL::String& to) { @@ -1300,13 +1424,8 @@ namespace MobileGL { FilterUnsupportedGpuShaderInt64(source); CoerceUniformBlockPackingToStd140(source); - // Some shader packs define helpers with built-in GLSL names such as round(), tanh(), or fma(). - // These may pass OpenGL-style validation but fail when recompiled for Vulkan/SPIR-V generation. - RenameBuiltinShadowingFunction(source, "round", "mg_round"); - RenameBuiltinShadowingFunction(source, "tanh", "mg_tanh"); - RenameBuiltinShadowingFunction(source, "fma", "mg_fma"); - RenameBuiltinShadowingFunction(source, "min3", "mg_min3"); - RenameBuiltinShadowingFunction(source, "max3", "mg_max3"); + RenameBuiltinShadowingFunctions(source); + ModernizeLegacyGLSL(stage, source); InjectDepthRangeBuiltinShim(stage, source); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp new file mode 100644 index 00000000..6fc8adfa --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp @@ -0,0 +1,62 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.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 "RenameBuiltinShadowingFunctionsPass.h" + +#include +#include + +#include "../EsslBuiltinFunctionNames.h" +#include "spirv.hpp" +#include "source/opt/def_use_manager.h" +#include "source/opt/instruction.h" +#include "source/opt/ir_context.h" +#include "source/opt/module.h" +#include "source/util/make_unique.h" + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + spvtools::opt::Pass::Status RenameBuiltinShadowingFunctionsPass::Process() { + Bool modified = false; + auto* irContext = context(); + auto* defUseMgr = irContext->get_def_use_mgr(); + + for (auto& debugInst : irContext->debugs2()) { + if (debugInst.opcode() != spv::Op::OpName || debugInst.NumInOperands() < 2) { + continue; + } + const auto* target = defUseMgr->GetDef(debugInst.GetSingleWordInOperand(0)); + if (target == nullptr || target->opcode() != spv::Op::OpFunction) { + continue; + } + + // glslang mangles function OpNames as "name("; the base name is + // everything before the '(' (entry points like "main" carry no mangling). + const std::string mangled = debugInst.GetInOperand(1).AsString(); + const std::string_view baseName = + std::string_view(mangled).substr(0, mangled.find('(')); + if (!IsEsslBuiltinFunctionName(baseName)) { + continue; + } + + debugInst.SetInOperand(1, spvtools::utils::MakeVector( + "mg_" + mangled)); + modified = true; + } + + return modified ? Status::SuccessWithChange : Status::SuccessWithoutChange; + } + + spvtools::Optimizer::PassToken + RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.h new file mode 100644 index 00000000..3db79826 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.h @@ -0,0 +1,46 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // Desktop GLSL lets a shader redefine a builtin function (round, fma, ...) and + // shadow it; ESSL 3.x forbids the redefinition, so when SPIRV-Cross re-emits the + // function under its original OpName a strict ES driver rejects the shader with a + // redefinition error. Prefix the OpName of every user-defined function whose base + // name collides with an ESSL builtin (plus the min3/max3 trinary extension names) + // with "mg_". Renaming a user function is always semantics-preserving: its + // definition and every call site go through the same result id, while calls to + // the real builtin never resolve to a user function id in SPIR-V. + // + // This is the BACKSTOP half of the rename. The primary half is the lexical + // RenameBuiltinShadowingFunctions in ShaderSourceProcessor, which has to run + // before the parse - glslang's relaxed parse rejects some shadowing overload + // shapes outright, and a shadowed builtin may itself need an extension the + // declared #version does not enable. This pass catches what a lexical scan + // cannot see (macro-expanded definitions) and is idempotent: an already + // renamed mg_* name is not in the builtin table. + // + // Both halves share MG_Util/ShaderTranspiler/EsslBuiltinFunctionNames.h, so the + // covered name set cannot drift between them. + class RenameBuiltinShadowingFunctionsPass : public spvtools::opt::Pass { + public: + const char* name() const override { return "rename-builtin-shadowing-functions"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateRenameBuiltinShadowingFunctionsPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL