diff --git a/CMakeLists.txt b/CMakeLists.txt index dee05153..77305a17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.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 0cf6a9fc..be4bdd03 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -2792,13 +2792,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // 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. + // GL_NV_shader_noperspective_interpolation : require`; a driver without that extension + // rejects the require. So on such devices emulate screen-linear interpolation instead + // (pre-multiply outputs by gl_Position.w, recover inputs via gl_FragCoord.w) and drop + // the decoration - exact, extension-free. Devices that have the extension keep the + // decoration and let the hardware do it natively. Vector noperspectiveSpirv; if (!g_GLESCapabilities.SupportsNoperspectiveInterpolation && - MG_Util::ShaderTranspiler::ShaderCompiler::StripNoPerspectiveForEssl( + MG_Util::ShaderTranspiler::ShaderCompiler::EmulateNoPerspectiveForEssl( *effectiveSpirv, noperspectiveSpirv) && !noperspectiveSpirv.empty()) { effectiveSpirv = &noperspectiveSpirv; diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 328668da..75f4405f 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -1257,6 +1257,185 @@ TEST_F(ProgramUtilTest, StripNoPerspectivePassRemovesBothDecorateForms) { << "Location decorations must survive:\n" << outputText; } +// Phase 2 emulation - fragment side. On a device without the NV extension the NoPerspective input is +// recovered as `load * gl_FragCoord.w` and the decoration removed; gl_FragCoord is synthesized because +// the shader did not otherwise use it. The emulated SPIR-V must validate and decompile without the +// extension require. +TEST_F(ProgramUtilTest, EmulateNoperspectiveFragmentRecoversWithFragCoordW) { + using namespace MG_Util::ShaderTranspiler; + + String fs = R"(#version 330 core +noperspective in vec4 vColor; +out vec4 f; +void main() { f = vColor; } +)"; + ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = fs}; + auto res = ShaderCompiler::CompileShader(attrib); + if (!res) FAIL() << "compile: " << res.error().log; + ProgramAttrib pa{.shaders = {res.value()}}; + auto pr = ShaderCompiler::LinkProgram(pa); + if (!pr) FAIL() << "link: " << pr.error().log; + ProgramBinaryAttrib ba{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *pr.value()}; + auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba); + if (!br) FAIL() << "spirv: " << br.error().log; + ASSERT_EQ(br.value().size(), 1u); + + Vector emulated; + ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(br.value()[0], emulated)); + ASSERT_FALSE(emulated.empty()); + + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String dis; + ASSERT_TRUE(tools.Disassemble(emulated, &dis)); + ASSERT_TRUE(tools.Validate(emulated)) << "emulated SPIR-V must be valid:\n" << dis; + EXPECT_EQ(dis.find("NoPerspective"), String::npos) << "decoration must be stripped:\n" << dis; + EXPECT_NE(dis.find("FragCoord"), String::npos) << "gl_FragCoord must be synthesized:\n" << dis; + EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos) << "the recovery multiply must be present:\n" << dis; + + SpvcSession session(emulated, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) FAIL() << "decompile: " << essl.error().log; + EXPECT_EQ(essl.value().find("noperspective"), String::npos) << essl.value(); + EXPECT_EQ(essl.value().find("GL_NV_shader_noperspective_interpolation"), String::npos) << essl.value(); + EXPECT_NE(essl.value().find("gl_FragCoord"), String::npos) << "recovery must reference gl_FragCoord:\n" << essl.value(); +} + +// Phase 2 emulation - vertex side. The NoPerspective output is pre-multiplied by gl_Position.w before +// return and the decoration removed. Emulated SPIR-V must validate and decompile without the extension. +TEST_F(ProgramUtilTest, EmulateNoperspectiveVertexPreMultipliesByPositionW) { + using namespace MG_Util::ShaderTranspiler; + + String vs = R"(#version 330 core +in vec4 pos; +noperspective out vec4 vColor; +void main() { gl_Position = pos; vColor = pos; } +)"; + ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = vs}; + auto res = ShaderCompiler::CompileShader(attrib); + if (!res) FAIL() << "compile: " << res.error().log; + ProgramAttrib pa{.shaders = {res.value()}}; + auto pr = ShaderCompiler::LinkProgram(pa); + if (!pr) FAIL() << "link: " << pr.error().log; + ProgramBinaryAttrib ba{.shaderTypes = {GL_VERTEX_SHADER}, .program = *pr.value()}; + auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba); + if (!br) FAIL() << "spirv: " << br.error().log; + ASSERT_EQ(br.value().size(), 1u); + + Vector emulated; + ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(br.value()[0], emulated)); + ASSERT_FALSE(emulated.empty()); + + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String dis; + ASSERT_TRUE(tools.Disassemble(emulated, &dis)); + ASSERT_TRUE(tools.Validate(emulated)) << "emulated SPIR-V must be valid:\n" << dis; + EXPECT_EQ(dis.find("NoPerspective"), String::npos) << "decoration must be stripped:\n" << dis; + EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos) << "the pre-multiply must be present:\n" << dis; + + SpvcSession session(emulated, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + if (!essl) FAIL() << "decompile: " << essl.error().log; + EXPECT_EQ(essl.value().find("noperspective"), String::npos) << essl.value(); + EXPECT_NE(essl.value().find("gl_Position"), String::npos) << "pre-multiply must reference gl_Position:\n" << essl.value(); +} + +namespace { +// Compiles one shader stage through the full pipeline and returns its SPIR-V, or fails the test. +MobileGL::Vector CompileStageSpirv(GLenum type, const char* src) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib attrib{.shaderType = type, .sourceStr = src}; + auto res = ShaderCompiler::CompileShader(attrib); + EXPECT_TRUE(static_cast(res)) << (res ? "" : res.error().log); + if (!res) return {}; + ProgramAttrib pa{.shaders = {res.value()}}; + auto pr = ShaderCompiler::LinkProgram(pa); + EXPECT_TRUE(static_cast(pr)) << (pr ? "" : pr.error().log); + if (!pr) return {}; + ProgramBinaryAttrib ba{.shaderTypes = {type}, .program = *pr.value()}; + auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba); + EXPECT_TRUE(static_cast(br)) << (br ? "" : br.error().log); + if (!br || br.value().empty()) return {}; + return br.value()[0]; +} +} // namespace + +// Regression: the vertex pre-multiply must be applied exactly once (in main), not once per function. +// glslang does not inline, so a helper function survives as its own OpFunction; instrumenting its +// return too would scale the varying by gl_Position.w twice (w^2). +TEST_F(ProgramUtilTest, EmulateNoperspectiveVertexWithHelperScalesExactlyOnce) { + using namespace MG_Util::ShaderTranspiler; + // helper() returns via OpReturnValue and adds (no vector*scalar), so the ONLY OpVectorTimesScalar + // in the module is the emulation's pre-multiply. The old all-functions code injected it at both + // helper's and main's return -> count 2; restricted to the entry function it is 1. + auto spirv = CompileStageSpirv(GL_VERTEX_SHADER, R"(#version 330 core +in vec4 pos; +noperspective out vec4 vColor; +vec4 helper(vec4 x) { return x + vec4(1.0); } +void main() { gl_Position = pos; vColor = helper(pos); } +)"); + ASSERT_FALSE(spirv.empty()); + + Vector emulated; + ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated)); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String dis; + ASSERT_TRUE(tools.Disassemble(emulated, &dis)); + ASSERT_TRUE(tools.Validate(emulated)) << dis; + + SizeT count = 0, off = 0; + while ((off = dis.find("OpVectorTimesScalar", off)) != String::npos) { + ++count; + off += std::strlen("OpVectorTimesScalar"); + } + EXPECT_EQ(count, 1u) << "the gl_Position.w pre-multiply must happen exactly once, not per function:\n" << dis; +} + +// Regression: a single-component read (vColor.x), which glslang lowers via OpAccessChain, must still be +// recovered with gl_FragCoord.w - not silently left un-scaled. +TEST_F(ProgramUtilTest, EmulateNoperspectiveFragmentComponentReadIsRecovered) { + using namespace MG_Util::ShaderTranspiler; + auto spirv = CompileStageSpirv(GL_FRAGMENT_SHADER, R"(#version 330 core +noperspective in vec4 vColor; +out vec4 f; +void main() { f = vec4(vColor.x); } +)"); + ASSERT_FALSE(spirv.empty()); + + Vector emulated; + ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated)); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String dis; + ASSERT_TRUE(tools.Disassemble(emulated, &dis)); + ASSERT_TRUE(tools.Validate(emulated)) << dis; + EXPECT_EQ(dis.find("NoPerspective"), String::npos) << dis; + EXPECT_NE(dis.find("FragCoord"), String::npos) + << "the component read must still be recovered via gl_FragCoord.w:\n" << dis; +} + +// Coverage: a scalar float varying exercises the OpFMul path; a vector varying the OpVectorTimesScalar +// path; multiple noperspective varyings in one stage are all handled. +TEST_F(ProgramUtilTest, EmulateNoperspectiveHandlesScalarAndMultipleVaryings) { + using namespace MG_Util::ShaderTranspiler; + auto spirv = CompileStageSpirv(GL_FRAGMENT_SHADER, R"(#version 330 core +noperspective in float a; +noperspective in vec2 b; +out vec4 f; +void main() { f = vec4(a, b, 1.0); } +)"); + ASSERT_FALSE(spirv.empty()); + + Vector emulated; + ASSERT_TRUE(ShaderCompiler::EmulateNoPerspectiveForEssl(spirv, emulated)); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String dis; + ASSERT_TRUE(tools.Disassemble(emulated, &dis)); + ASSERT_TRUE(tools.Validate(emulated)) << dis; + EXPECT_EQ(dis.find("NoPerspective"), String::npos) << dis; + EXPECT_NE(dis.find("OpFMul"), String::npos) << "the scalar varying must scale with OpFMul:\n" << dis; + EXPECT_NE(dis.find("OpVectorTimesScalar"), String::npos) + << "the vector varying must scale with OpVectorTimesScalar:\n" << dis; +} + const char* vs_location = R"(#version 460 in vec4 Position; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index efcd2d0b..02f513fd 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -21,6 +21,7 @@ #include "SpirvPasses/RebaseInstanceIndexPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" #include "SpirvPasses/StripNoPerspectivePass.h" +#include "SpirvPasses/EmulateNoPerspectivePass.h" #include "spirv-tools/libspirv.h" #include "spirv-tools/optimizer.hpp" @@ -347,6 +348,18 @@ namespace MobileGL { return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options); } + bool ShaderCompiler::EmulateNoPerspectiveForEssl(const Vector& inputBinary, + Vector& outputBinary) { + using namespace spvtools; + OptimizerOptions options; + options.set_run_validator(false); + + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass()); + + 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 76c65645..4f282fb8 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -38,6 +38,11 @@ namespace MobileGL { // (SPIRV-Cross would otherwise require that extension and the driver would reject it). static bool StripNoPerspectiveForEssl(const Vector& inputBinary, Vector& outputBinary); + // Emulates noperspective (screen-linear) interpolation via gl_Position.w / gl_FragCoord.w + // so no NV extension is needed; strips what it cannot emulate. DirectGLES fallback for + // devices lacking GL_NV_shader_noperspective_interpolation. See EmulateNoPerspectivePass. + static bool EmulateNoPerspectiveForEssl(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/SpirvPasses/EmulateNoPerspectivePass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp new file mode 100644 index 00000000..ee08885c --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp @@ -0,0 +1,407 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.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 "EmulateNoPerspectivePass.h" + +#include "spirv.hpp" +#include "source/opt/constants.h" +#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/opt/type_manager.h" +#include "source/opt/types.h" +#include "source/util/make_unique.h" + +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + namespace analysis = spvtools::opt::analysis; + + spv::ExecutionModel EntryExecutionModel(IRContext* ctx) { + for (Instruction& ep : ctx->module()->entry_points()) { + return static_cast(ep.GetSingleWordInOperand(0)); + } + return spv::ExecutionModel::Max; + } + + uint32_t VariablePointeeType(IRContext* ctx, Instruction* var) { + Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(var->type_id()); + // OpTypePointer + return ptrType->GetSingleWordInOperand(1); + } + + // If |typeId| is float or a vector of float, returns true and reports the scalar float + // type and whether it is a vector. Matrices, structs, ints etc. are not emulatable. + bool IsFloatScalarOrVector(IRContext* ctx, uint32_t typeId, uint32_t& floatTypeId, bool& isVector) { + Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId); + if (t == nullptr) return false; + if (t->opcode() == spv::Op::OpTypeFloat) { + floatTypeId = typeId; + isVector = false; + return true; + } + if (t->opcode() == spv::Op::OpTypeVector) { + const uint32_t comp = t->GetSingleWordInOperand(0); + Instruction* ct = ctx->get_def_use_mgr()->GetDef(comp); + if (ct != nullptr && ct->opcode() == spv::Op::OpTypeFloat) { + floatTypeId = comp; + isVector = true; + return true; + } + } + return false; + } + + uint32_t PointerTypeTo(IRContext* ctx, uint32_t pointeeId, spv::StorageClass sc) { + analysis::Type* pointee = ctx->get_type_mgr()->GetType(pointeeId); + analysis::Pointer ptr(pointee, sc); + return ctx->get_type_mgr()->GetTypeInstruction(&ptr); + } + + uint32_t V4FloatType(IRContext* ctx) { + analysis::Float f(32); + analysis::Type* freg = ctx->get_type_mgr()->GetRegisteredType(&f); + analysis::Vector v(freg, 4); + return ctx->get_type_mgr()->GetTypeInstruction(&v); + } + + uint32_t FloatType(IRContext* ctx) { + analysis::Float f(32); + return ctx->get_type_mgr()->GetTypeInstruction(&f); + } + + uint32_t SignedIntConstant(IRContext* ctx, int32_t value) { + analysis::Integer i(32, true); + analysis::Type* reg = ctx->get_type_mgr()->GetRegisteredType(&i); + const analysis::Constant* c = + ctx->get_constant_mgr()->GetConstant(reg, {static_cast(value)}); + return ctx->get_constant_mgr()->GetDefiningInstruction(c)->result_id(); + } + + // Multiply |valueId| (of type |valueTypeId|) by the scalar |scalarId|, inserting the op + // before |before|. Returns the product's id. + uint32_t InsertScale(IRContext* ctx, Instruction* before, uint32_t valueTypeId, + uint32_t valueId, uint32_t scalarId, bool isVector) { + const uint32_t productId = ctx->TakeNextId(); + const spv::Op op = isVector ? spv::Op::OpVectorTimesScalar : spv::Op::OpFMul; + before->InsertBefore(spvtools::MakeUnique( + ctx, op, valueTypeId, productId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {valueId}}, + {SPV_OPERAND_TYPE_ID, {scalarId}}})); + return productId; + } + + // --- Vertex stage: gl_Position discovery ------------------------------------------ + + // Finds gl_Position as member |memberIndex| of a gl_PerVertex-style block whose Output + // variable is |blockVarId|; |v4floatTypeId| is that member's (vec4) type. Returns false + // if gl_Position is not a block member (older plain-variable form is left to the strip). + bool FindPositionBlock(IRContext* ctx, uint32_t& blockVarId, uint32_t& memberIndex, + uint32_t& v4floatTypeId) { + uint32_t structId = 0; + uint32_t member = 0; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 && + static_cast(ann.GetSingleWordInOperand(2)) == + spv::Decoration::BuiltIn && + static_cast(ann.GetSingleWordInOperand(3)) == + spv::BuiltIn::Position) { + structId = ann.GetSingleWordInOperand(0); + member = ann.GetSingleWordInOperand(1); + break; + } + } + if (structId == 0) return false; + + Instruction* structType = ctx->get_def_use_mgr()->GetDef(structId); + if (structType == nullptr || member >= structType->NumInOperands()) return false; + v4floatTypeId = structType->GetSingleWordInOperand(member); + + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() == spv::Op::OpVariable && + static_cast(inst.GetSingleWordInOperand(0)) == + spv::StorageClass::Output && + VariablePointeeType(ctx, &inst) == structId) { + blockVarId = inst.result_id(); + memberIndex = member; + return true; + } + } + return false; + } + + // --- Fragment stage: gl_FragCoord discovery/synthesis ----------------------------- + + Instruction* FindBuiltinInput(IRContext* ctx, spv::BuiltIn builtin) { + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() != spv::Op::OpDecorate || ann.NumInOperands() < 3) continue; + if (static_cast(ann.GetSingleWordInOperand(1)) != + spv::Decoration::BuiltIn) + continue; + if (static_cast(ann.GetSingleWordInOperand(2)) != builtin) continue; + Instruction* var = ctx->get_def_use_mgr()->GetDef(ann.GetSingleWordInOperand(0)); + if (var != nullptr && var->opcode() == spv::Op::OpVariable && + static_cast(var->GetSingleWordInOperand(0)) == + spv::StorageClass::Input) { + return var; + } + } + return nullptr; + } + + uint32_t SynthesizeFragCoord(IRContext* ctx, uint32_t v4floatTypeId) { + const uint32_t ptrType = PointerTypeTo(ctx, v4floatTypeId, spv::StorageClass::Input); + const uint32_t varId = ctx->TakeNextId(); + ctx->AddGlobalValue(spvtools::MakeUnique( + ctx, spv::Op::OpVariable, ptrType, varId, + std::initializer_list{ + {SPV_OPERAND_TYPE_STORAGE_CLASS, + {static_cast(spv::StorageClass::Input)}}})); + ctx->AddAnnotationInst(spvtools::MakeUnique( + ctx, spv::Op::OpDecorate, 0, 0, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {varId}}, + {SPV_OPERAND_TYPE_DECORATION, + {static_cast(spv::Decoration::BuiltIn)}}, + {SPV_OPERAND_TYPE_LITERAL_INTEGER, + {static_cast(spv::BuiltIn::FragCoord)}}})); + for (Instruction& ep : ctx->module()->entry_points()) { + ep.AddOperand({SPV_OPERAND_TYPE_ID, {varId}}); + } + return varId; + } + } // namespace + + spvtools::opt::Pass::Status EmulateNoPerspectivePass::Process() { + auto* ctx = context(); + const spv::ExecutionModel model = EntryExecutionModel(ctx); + const bool isVertex = model == spv::ExecutionModel::Vertex; + const bool isFragment = model == spv::ExecutionModel::Fragment; + + // Collect NoPerspective-decorated plain variables and every NoPerspective annotation. + std::vector plainVarIds; + std::vector decorationsToKill; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 2 && + static_cast(ann.GetSingleWordInOperand(1)) == + spv::Decoration::NoPerspective) { + plainVarIds.push_back(ann.GetSingleWordInOperand(0)); + decorationsToKill.push_back(&ann); + } else if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 3 && + static_cast(ann.GetSingleWordInOperand(2)) == + spv::Decoration::NoPerspective) { + // Block-member noperspective is not emulated here; the decoration is stripped + // (smooth fallback) so SPIRV-Cross does not require the NV extension. + decorationsToKill.push_back(&ann); + } + } + + if (decorationsToKill.empty()) { + return Status::SuccessWithoutChange; + } + + const spv::StorageClass wantStorage = + isVertex ? spv::StorageClass::Output : spv::StorageClass::Input; + + // Emulatable = plain variable of the stage's interface direction, float or floatN. + struct Target { + Instruction* var; + uint32_t typeId; + uint32_t floatTypeId; + bool isVector; + }; + std::vector targets; + if (isVertex || isFragment) { + for (const uint32_t id : plainVarIds) { + Instruction* var = ctx->get_def_use_mgr()->GetDef(id); + if (var == nullptr || var->opcode() != spv::Op::OpVariable) continue; + if (static_cast(var->GetSingleWordInOperand(0)) != wantStorage) + continue; + const uint32_t pointee = VariablePointeeType(ctx, var); + uint32_t floatTypeId = 0; + bool isVector = false; + if (IsFloatScalarOrVector(ctx, pointee, floatTypeId, isVector)) { + targets.push_back({var, pointee, floatTypeId, isVector}); + } + } + } + + // Force highp on the varyings we emulate: the a*w round-trip overflows a mediump (fp16) + // varying at large clip-space w. Dropping RelaxedPrecision makes SPIRV-Cross emit them + // highp on both stages, keeping the emulation exact. Only touches emulated variables. + if (!targets.empty()) { + std::vector targetIds; + targetIds.reserve(targets.size()); + for (const Target& t : targets) targetIds.push_back(t.var->result_id()); + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 2 && + static_cast(ann.GetSingleWordInOperand(1)) == + spv::Decoration::RelaxedPrecision && + std::find(targetIds.begin(), targetIds.end(), + ann.GetSingleWordInOperand(0)) != targetIds.end()) { + decorationsToKill.push_back(&ann); + } + } + } + + if (isVertex && !targets.empty()) { + uint32_t blockVarId = 0; + uint32_t memberIndex = 0; + uint32_t v4floatTypeId = 0; + if (FindPositionBlock(ctx, blockVarId, memberIndex, v4floatTypeId)) { + const uint32_t ptrOutV4 = + PointerTypeTo(ctx, v4floatTypeId, spv::StorageClass::Output); + const uint32_t memberConst = SignedIntConstant(ctx, static_cast(memberIndex)); + const uint32_t floatTy = FloatType(ctx); + + uint32_t entryFuncId = 0; + for (Instruction& ep : ctx->module()->entry_points()) { + // OpEntryPoint "name" + entryFuncId = ep.GetSingleWordInOperand(1); + break; + } + + // Pre-multiply every target output by gl_Position.w before each return of the + // ENTRY function only. glslang does not inline, so a called helper survives as + // its own OpFunction; instrumenting its returns too would scale the varying + // more than once (w^2), breaking the identity. + for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end(); ++funcIt) { + if (funcIt->result_id() != entryFuncId) continue; + funcIt->ForEachInst([&](Instruction* inst) { + if (inst->opcode() != spv::Op::OpReturn && + inst->opcode() != spv::Op::OpReturnValue) { + return; + } + const uint32_t posPtrId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpAccessChain, ptrOutV4, posPtrId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {blockVarId}}, + {SPV_OPERAND_TYPE_ID, {memberConst}}})); + const uint32_t posId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, v4floatTypeId, posId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {posPtrId}}})); + const uint32_t wId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpCompositeExtract, floatTy, wId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {posId}}, + {SPV_OPERAND_TYPE_LITERAL_INTEGER, {3u}}})); + for (const Target& t : targets) { + const uint32_t valId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, t.typeId, valId, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {t.var->result_id()}}})); + const uint32_t scaledId = + InsertScale(ctx, inst, t.typeId, valId, wId, t.isVector); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpStore, 0, 0, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {t.var->result_id()}}, + {SPV_OPERAND_TYPE_ID, {scaledId}}})); + } + }); + } + } + } + + if (isFragment && !targets.empty()) { + Instruction* fragCoord = FindBuiltinInput(ctx, spv::BuiltIn::FragCoord); + uint32_t fragCoordId = 0; + uint32_t v4floatTypeId = 0; + if (fragCoord != nullptr) { + fragCoordId = fragCoord->result_id(); + v4floatTypeId = VariablePointeeType(ctx, fragCoord); + } else { + v4floatTypeId = V4FloatType(ctx); + fragCoordId = SynthesizeFragCoord(ctx, v4floatTypeId); + } + const uint32_t floatTy = FloatType(ctx); + + auto* defUse = ctx->get_def_use_mgr(); + for (const Target& t : targets) { + // Collect every load that reads the varying. glslang lowers a whole-variable + // read to OpLoad(var), but a single-component read (v.x) to + // OpAccessChain(var) + OpLoad(chain). Both must be scaled; the identity is + // per-component, so scaling one loaded component by gl_FragCoord.w is valid. + std::vector loads; + defUse->ForEachUser(t.var, [&](Instruction* user) { + if (user->opcode() == spv::Op::OpLoad && + user->GetSingleWordInOperand(0) == t.var->result_id()) { + loads.push_back(user); + } else if (user->opcode() == spv::Op::OpAccessChain && + user->GetSingleWordInOperand(0) == t.var->result_id()) { + const uint32_t chainId = user->result_id(); + defUse->ForEachUser(user, [&](Instruction* chainUser) { + if (chainUser->opcode() == spv::Op::OpLoad && + chainUser->GetSingleWordInOperand(0) == chainId) { + loads.push_back(chainUser); + } + }); + } + }); + + // Rewrite `%r = OpLoad %ty %ptr` into + // %orig = OpLoad %ty %ptr + // %fc = OpLoad %v4float %fragCoord + // %w = OpCompositeExtract %float %fc 3 + // %r = OpVectorTimesScalar/OpFMul %ty %orig %w (reuse %r: uses stay intact) + // The op is chosen from the LOAD's own result type: a whole-vector load scales + // with OpVectorTimesScalar, a scalar component load with OpFMul. + for (Instruction* load : loads) { + const uint32_t loadType = load->type_id(); + uint32_t componentFloat = 0; + bool loadIsVector = false; + if (!IsFloatScalarOrVector(ctx, loadType, componentFloat, loadIsVector)) { + continue; + } + const uint32_t ptrId = load->GetSingleWordInOperand(0); + const uint32_t origId = ctx->TakeNextId(); + load->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, loadType, origId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {ptrId}}})); + const uint32_t fcId = ctx->TakeNextId(); + load->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, v4floatTypeId, fcId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {fragCoordId}}})); + const uint32_t wId = ctx->TakeNextId(); + load->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpCompositeExtract, floatTy, wId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {fcId}}, + {SPV_OPERAND_TYPE_LITERAL_INTEGER, {3u}}})); + load->SetOpcode(loadIsVector ? spv::Op::OpVectorTimesScalar : spv::Op::OpFMul); + load->SetInOperands(Instruction::OperandList{ + {SPV_OPERAND_TYPE_ID, {origId}}, {SPV_OPERAND_TYPE_ID, {wId}}}); + } + } + } + + // Strip every NoPerspective decoration: emulated varyings now transport smooth, and + // non-emulatable ones fall back to smooth. + for (Instruction* dec : decorationsToKill) { + ctx->KillInst(dec); + } + + ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.h new file mode 100644 index 00000000..7c8b88c2 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.h @@ -0,0 +1,41 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // Emulates 'noperspective' (screen-linear) interpolation on GLES devices that lack + // GL_NV_shader_noperspective_interpolation, so no NV extension is required. The hardware + // interpolates perspective-correct; screen-linear L(a) is recovered from the identity + // L(a) = P(a * w) * gl_FragCoord.w + // where P is perspective-correct interpolation and w is the vertex clip-space w. So each + // NoPerspective-decorated output is pre-multiplied by gl_Position.w in the vertex stage + // and each NoPerspective-decorated input is multiplied by gl_FragCoord.w in the fragment + // stage; the decoration is then removed so the varying transports smooth. This is exact + // (modulo float precision - the emulated varyings want highp). + // + // Scope: plain interface variables of float or floatN type. Anything it cannot emulate + // (interface-block members, matrices, or a stage lacking the needed builtin) has its + // NoPerspective decoration stripped instead, degrading to smooth - the same result the + // extension-less fallback produced before, and never invalid SPIR-V. DirectGLES only. + class EmulateNoPerspectivePass : public spvtools::opt::Pass { + public: + const char* name() const override { return "emulate-noperspective"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateEmulateNoPerspectivePass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL