diff --git a/CMakeLists.txt b/CMakeLists.txt index fe5df977..dee05153 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -192,6 +192,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 957b1a91..0cf6a9fc 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -2790,6 +2790,20 @@ namespace MobileGL::MG_Backend::DirectGLES { effectiveSpirv = &uboPrecisionSpirv; } + // noperspective is core desktop GLSL and reaches here as the SPIR-V NoPerspective + // decoration. SPIRV-Cross renders it as ESSL `noperspective` + `#extension + // GL_NV_shader_noperspective_interpolation : require`; on a driver without that + // extension the require fails, so strip the decoration first and let the varying + // fall back to smooth interpolation. Devices that have the extension keep the + // decoration and get true screen-linear interpolation. + Vector noperspectiveSpirv; + if (!g_GLESCapabilities.SupportsNoperspectiveInterpolation && + MG_Util::ShaderTranspiler::ShaderCompiler::StripNoPerspectiveForEssl( + *effectiveSpirv, noperspectiveSpirv) && + !noperspectiveSpirv.empty()) { + effectiveSpirv = &noperspectiveSpirv; + } + MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv, MG_Util::ShaderTranspiler::SessionUsageBit::Transpile); diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 565712b5..328668da 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -1089,6 +1089,174 @@ TEST_F(ProgramUtilTest, CompileFragmentShaderWithDiscard) { } } +// noperspective is core desktop GLSL (1.30+) and maps to the SPIR-V NoPerspective decoration. It must +// reach glslang (not be stripped as text) so the SPIR-V carries the decoration; SPIRV-Cross then emits +// ESSL `noperspective` + the GL_NV_shader_noperspective_interpolation extension. Shader packs +// (Iris/Complementary) depend on it, and KHR-GL33.glsl_noperspective fails if the result matches +// smooth. This is the DirectGLES path with the NV extension available (SPIRV-Cross's default). +TEST_F(ProgramUtilTest, NoperspectiveInterpolationSurvivesToEssl) { + using namespace MG_Util::ShaderTranspiler; + + String fs = R"(#version 330 core +noperspective in vec4 vColor; +out vec4 fragColor; +void main() { fragColor = vColor; } +)"; + ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs}; + auto res = ShaderCompiler::CompileShader(attrib); + if (!res) FAIL() << "compile errc: " << res.error().errc << "\nlog: " << res.error().log; + + ProgramAttrib programAttrib{.shaders = {res.value()}}; + auto program_res = ShaderCompiler::LinkProgram(programAttrib); + if (!program_res) FAIL() << "link errc: " << program_res.error().errc << "\nlog: " << program_res.error().log; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program_res.value()}; + auto bin_res = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!bin_res) FAIL() << "spirv errc: " << bin_res.error().errc << "\nlog: " << bin_res.error().log; + ASSERT_EQ(bin_res.value().size(), 1u); + + SpvcSession session(bin_res.value()[0], SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) FAIL() << "decompile errc: " << essl.error().errc << "\nlog: " << essl.error().log; + + EXPECT_NE(essl.value().find("noperspective"), String::npos) + << "noperspective was lost before it reached SPIR-V:\n" << essl.value(); + EXPECT_NE(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos) + << "SPIRV-Cross must require the NV extension for ES noperspective:\n" << essl.value(); +} + +// The old handling was a naked substring erase of "noperspective", so any identifier that merely +// contained those characters (a uniform named noperspectiveBlend, say) got mangled. Removing the +// strip fixes it - glslang, which is identifier-aware, is the only thing that should see the keyword. +TEST_F(ProgramUtilTest, PreprocessDoesNotCorruptIdentifiersContainingNoperspective) { + using namespace MG_Util::ShaderTranspiler; + + String source = R"(#version 330 core +uniform float noperspectiveBlend; +out vec4 fragColor; +void main() { fragColor = vec4(noperspectiveBlend); } +)"; + PreprocessShaderSource(ShaderStage::Fragment, source); + EXPECT_NE(source.find("noperspectiveBlend"), String::npos) + << "identifier was corrupted by substring stripping:\n" << source; +} + +// The DirectGLES fallback for devices without GL_NV_shader_noperspective_interpolation: stripping the +// NoPerspective decoration makes SPIRV-Cross emit a plain smooth varying with no `#extension … : +// require`, so the shader still compiles (rendering as smooth) instead of being rejected by the driver. +TEST_F(ProgramUtilTest, StripNoPerspectiveFallbackProducesPlainEssl) { + using namespace MG_Util::ShaderTranspiler; + + String fs = R"(#version 330 core +noperspective in vec4 vColor; +out vec4 fragColor; +void main() { fragColor = vColor; } +)"; + ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs}; + auto res = ShaderCompiler::CompileShader(attrib); + if (!res) FAIL() << "compile errc: " << res.error().errc << "\nlog: " << res.error().log; + + ProgramAttrib programAttrib{.shaders = {res.value()}}; + auto program_res = ShaderCompiler::LinkProgram(programAttrib); + if (!program_res) FAIL() << "link errc: " << program_res.error().errc << "\nlog: " << program_res.error().log; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program_res.value()}; + auto bin_res = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!bin_res) FAIL() << "spirv errc: " << bin_res.error().errc << "\nlog: " << bin_res.error().log; + ASSERT_EQ(bin_res.value().size(), 1u); + + // Precondition: with the decoration present the default decompile requires the NV extension. + { + SpvcSession session(bin_res.value()[0], SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) FAIL() << "decompile errc: " << essl.error().errc; + ASSERT_NE(essl.value().find("noperspective"), String::npos) << essl.value(); + } + + // The fallback strips the decoration -> plain smooth ESSL, no extension require. + Vector stripped; + ASSERT_TRUE(ShaderCompiler::StripNoPerspectiveForEssl(bin_res.value()[0], stripped)); + ASSERT_FALSE(stripped.empty()); + + SpvcSession session(stripped, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) FAIL() << "decompile errc: " << essl.error().errc << "\nlog: " << essl.error().log; + EXPECT_EQ(essl.value().find("noperspective"), String::npos) + << "the decoration should be gone:\n" << essl.value(); + EXPECT_EQ(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos) + << "no extension require without the decoration:\n" << essl.value(); +} + +// Directly exercises BOTH decoration forms StripNoPerspectivePass handles: a plain-variable +// OpDecorate NoPerspective (in-operand 1) and an interface-block-member OpMemberDecorate NoPerspective +// (in-operand 2). The ESSL round-trip tests above use only a scalar input, so they never reach the +// member-decorate branch, which a block varying like `in Block { noperspective vec4 c; }` (common in +// shader packs) produces. Unrelated decorations (Flat, Location) must survive untouched. +TEST_F(ProgramUtilTest, StripNoPerspectivePassRemovesBothDecorateForms) { + using namespace MG_Util::ShaderTranspiler; + + const String spirvText = R"( + OpCapability Shader + OpMemoryModel Logical GLSL450 + OpEntryPoint Fragment %main "main" %plainVar %blockVar %flatVar + OpExecutionMode %main OriginUpperLeft + OpName %main "main" + OpDecorate %plainVar Location 0 + OpDecorate %plainVar NoPerspective + OpMemberDecorate %Block 0 NoPerspective + OpDecorate %blockVar Location 1 + OpDecorate %flatVar Location 2 + OpDecorate %flatVar Flat + %void = OpTypeVoid + %mainFn = OpTypeFunction %void + %float = OpTypeFloat 32 + %v4float = OpTypeVector %float 4 + %int = OpTypeInt 32 1 + %inV4Ptr = OpTypePointer Input %v4float + %plainVar = OpVariable %inV4Ptr Input + %Block = OpTypeStruct %v4float + %inBlockPtr = OpTypePointer Input %Block + %blockVar = OpVariable %inBlockPtr Input + %inIntPtr = OpTypePointer Input %int + %flatVar = OpVariable %inIntPtr Input + %main = OpFunction %void None %mainFn + %mainBody = OpLabel + OpReturn + OpFunctionEnd +)"; + + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + Vector inputBinary; + ASSERT_TRUE(tools.Assemble(spirvText, &inputBinary)); + + const auto countNoPerspective = [](const String& text) { + SizeT count = 0, offset = 0; + while ((offset = text.find("NoPerspective", offset)) != String::npos) { + ++count; + offset += std::strlen("NoPerspective"); + } + return count; + }; + + String inputText; + ASSERT_TRUE(tools.Disassemble(inputBinary, &inputText)); + ASSERT_EQ(countNoPerspective(inputText), 2u) + << "fixture must carry both a plain and a member NoPerspective:\n" << inputText; + + Vector outputBinary; + ASSERT_TRUE(ShaderCompiler::StripNoPerspectiveForEssl(inputBinary, outputBinary)); + ASSERT_FALSE(outputBinary.empty()); + + String outputText; + ASSERT_TRUE(tools.Disassemble(outputBinary, &outputText)); + EXPECT_EQ(countNoPerspective(outputText), 0u) + << "both NoPerspective decorations (OpDecorate and OpMemberDecorate) must be stripped:\n" << outputText; + EXPECT_NE(outputText.find("Flat"), String::npos) + << "the unrelated Flat decoration must survive:\n" << outputText; + EXPECT_NE(outputText.find("Location"), String::npos) + << "Location decorations must survive:\n" << outputText; +} + const char* vs_location = R"(#version 460 in vec4 Position; diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp index 606769c1..15ee1748 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp @@ -811,6 +811,9 @@ namespace MobileGL::MG_Util::BackendLoader { if (std::strcmp(extension, "GL_EXT_blend_func_extended") == 0) { caps.SupportsDualSourceBlend = true; } + if (std::strcmp(extension, "GL_NV_shader_noperspective_interpolation") == 0) { + caps.SupportsNoperspectiveInterpolation = true; + } } } diff --git a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h index e9092dfb..12667d45 100644 --- a/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.h @@ -1050,6 +1050,11 @@ namespace MobileGL { // factors and layout(index = 1) fragment outputs. GLES core has no dual-source blending, // so without this a draw using a SRC1 factor cannot proceed. Bool SupportsDualSourceBlend = false; + // GL_NV_shader_noperspective_interpolation is present: the driver accepts the + // `noperspective` interpolation qualifier in ESSL. GLES core has none, so without this + // SPIRV-Cross's `#extension ... : require` would fail to compile and MobileGL falls back + // to stripping the NoPerspective decoration (smooth interpolation) via StripNoPerspectivePass. + Bool SupportsNoperspectiveInterpolation = false; // GL_RENDERER contains "ANGLE". Bool IsAngleRenderer = false; // GL_RENDERER contains both "ANGLE" and "llvmpipe". diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index a2f4ebbd..efcd2d0b 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -20,6 +20,7 @@ #include "SpirvPasses/LowerDrawParametersPass.h" #include "SpirvPasses/RebaseInstanceIndexPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" +#include "SpirvPasses/StripNoPerspectivePass.h" #include "spirv-tools/libspirv.h" #include "spirv-tools/optimizer.hpp" @@ -334,6 +335,18 @@ namespace MobileGL { return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options); } + bool ShaderCompiler::StripNoPerspectiveForEssl(const Vector& inputBinary, + Vector& outputBinary) { + using namespace spvtools; + OptimizerOptions options; + options.set_run_validator(false); + + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(StripNoPerspectivePass::CreateStripNoPerspectivePass()); + + return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options); + } + bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector& inputBinary, Vector& outputBinary) { using namespace spvtools; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 87065fa3..76c65645 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -33,6 +33,11 @@ namespace MobileGL { // Only for the DirectGLES transpile path. static bool StripUboMemberRelaxedPrecisionForEssl(const Vector& inputBinary, Vector& outputBinary); + // Removes NoPerspective decorations so SPIRV-Cross emits plain (smooth) ESSL varyings. + // DirectGLES fallback only, for devices lacking GL_NV_shader_noperspective_interpolation + // (SPIRV-Cross would otherwise require that extension and the driver would reject it). + static bool StripNoPerspectiveForEssl(const Vector& inputBinary, + Vector& outputBinary); // Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so // shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan // backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex, diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp index 47c9f234..d874e6b8 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderSourceProcessor.cpp @@ -1251,15 +1251,13 @@ namespace MobileGL { NormalizeLineDirectives(source); - // remove "noperspective" - const char* str_np = "noperspective"; - const SizeT len_np = strlen(str_np); - SizeT noperspectivePos = source.find(str_np); - while (noperspectivePos != String::npos) { - // + length of "\n" - source = source.replace(noperspectivePos, len_np, ""); - noperspectivePos = source.find(str_np); - } + // noperspective is intentionally NOT touched here. It is core in desktop GLSL (1.30+) + // and maps to the core SPIR-V NoPerspective decoration, which DirectVulkan renders + // natively and SPIRV-Cross turns into ESSL `noperspective` + the + // GL_NV_shader_noperspective_interpolation extension. The old naked substring erase + // both discarded that interpolation (shader packs need it) and corrupted any + // identifier that merely contained the word. The GLES fallback for devices without + // the extension lives in the backend, where device capabilities are known. FilterUnsupportedGpuShaderInt64(source); CoerceUniformBlockPackingToStd140(source); diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp new file mode 100644 index 00000000..b1e924e5 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp @@ -0,0 +1,72 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.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 "StripNoPerspectivePass.h" + +#include "spirv.hpp" +#include "source/opt/instruction.h" +#include "source/opt/ir_context.h" +#include "source/util/make_unique.h" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + + // OpDecorate [literals...] + // OpMemberDecorate [literals...] + constexpr uint32_t kDecorateDecorationOperand = 1; + constexpr uint32_t kMemberDecorateDecorationOperand = 2; + } // namespace + + spvtools::opt::Pass::Status StripNoPerspectivePass::Process() { + auto* irContext = context(); + + // Collect first: KillInst mutates the annotation list being walked. + std::vector toKill; + for (Instruction& annotation : irContext->annotations()) { + uint32_t decorationOperand = 0; + if (annotation.opcode() == spv::Op::OpDecorate) { + decorationOperand = kDecorateDecorationOperand; + } else if (annotation.opcode() == spv::Op::OpMemberDecorate) { + decorationOperand = kMemberDecorateDecorationOperand; + } else { + continue; + } + + if (annotation.NumInOperands() <= decorationOperand) { + continue; + } + if (static_cast(annotation.GetSingleWordInOperand(decorationOperand)) == + spv::Decoration::NoPerspective) { + toKill.push_back(&annotation); + } + } + + if (toKill.empty()) { + return Status::SuccessWithoutChange; + } + + for (Instruction* inst : toKill) { + irContext->KillInst(inst); + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken StripNoPerspectivePass::CreateStripNoPerspectivePass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.h new file mode 100644 index 00000000..c531ad89 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.h @@ -0,0 +1,35 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.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 { + // Removes the NoPerspective decoration from every interface variable and block member. + // DirectGLES fallback only, for devices that lack GL_NV_shader_noperspective_interpolation: + // SPIRV-Cross renders a NoPerspective-decorated varying as ESSL `noperspective` plus + // `#extension GL_NV_shader_noperspective_interpolation : require`, which such a driver + // rejects. Dropping the decoration falls the varying back to smooth (perspective-correct) + // interpolation - the same visible result the old text-level strip produced, but without + // corrupting identifiers and without touching DirectVulkan, where NoPerspective is native. + // (The exact screen-linear emulation via gl_Position.w / gl_FragCoord.w is a later step.) + class StripNoPerspectivePass : public spvtools::opt::Pass { + public: + const char* name() const override { return "strip-noperspective"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateStripNoPerspectivePass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL