[Feat] (ShaderTranspiler, DirectGLES): emulate noperspective on GLES devices lacking GL_NV_shader_noperspective_interpolation - EmulateNoPerspectivePass pre-multiplies each NoPerspective output by gl_Position.w in the vertex stage and recovers each input via gl_FragCoord.w in the fragment stage (exact screen-linear L = P(a*w)*gl_FragCoord.w, handling whole-variable and component/access-chain reads, scalar and vector varyings), forces highp on emulated varyings, and strips what it cannot emulate; replaces the smooth-strip fallback so no NV extension is ever required. Restricts the vertex pre-multiply to the entry function so a non-inlined helper cannot double-scale

This commit is contained in:
2026-07-20 23:48:48 -04:00
parent 202037b5a3
commit 79feeffd25
7 changed files with 652 additions and 5 deletions
+1
View File
@@ -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
+6 -5
View File
@@ -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<unsigned int> noperspectiveSpirv;
if (!g_GLESCapabilities.SupportsNoperspectiveInterpolation &&
MG_Util::ShaderTranspiler::ShaderCompiler::StripNoPerspectiveForEssl(
MG_Util::ShaderTranspiler::ShaderCompiler::EmulateNoPerspectiveForEssl(
*effectiveSpirv, noperspectiveSpirv) &&
!noperspectiveSpirv.empty()) {
effectiveSpirv = &noperspectiveSpirv;
@@ -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<uint32_t> 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<uint32_t> 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<uint32_t> 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<bool>(res)) << (res ? "" : res.error().log);
if (!res) return {};
ProgramAttrib pa{.shaders = {res.value()}};
auto pr = ShaderCompiler::LinkProgram(pa);
EXPECT_TRUE(static_cast<bool>(pr)) << (pr ? "" : pr.error().log);
if (!pr) return {};
ProgramBinaryAttrib ba{.shaderTypes = {type}, .program = *pr.value()};
auto br = ShaderCompiler::GetSpirvBinaryFromProgram(ba);
EXPECT_TRUE(static_cast<bool>(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<uint32_t> 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<uint32_t> 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<uint32_t> 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;
@@ -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<Uint32>& inputBinary,
Vector<uint32_t>& 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<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
@@ -38,6 +38,11 @@ namespace MobileGL {
// (SPIRV-Cross would otherwise require that extension and the driver would reject it).
static bool StripNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& 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<Uint32>& inputBinary,
Vector<uint32_t>& 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,
@@ -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 <algorithm>
#include <vector>
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<spv::ExecutionModel>(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 <storage-class> <pointee>
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<uint32_t>(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<Instruction>(
ctx, op, valueTypeId, productId,
std::initializer_list<Operand>{{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<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(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<spv::StorageClass>(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<spv::Decoration>(ann.GetSingleWordInOperand(1)) !=
spv::Decoration::BuiltIn)
continue;
if (static_cast<spv::BuiltIn>(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<spv::StorageClass>(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<Instruction>(
ctx, spv::Op::OpVariable, ptrType, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS,
{static_cast<uint32_t>(spv::StorageClass::Input)}}}));
ctx->AddAnnotationInst(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {varId}},
{SPV_OPERAND_TYPE_DECORATION,
{static_cast<uint32_t>(spv::Decoration::BuiltIn)}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER,
{static_cast<uint32_t>(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<uint32_t> plainVarIds;
std::vector<Instruction*> decorationsToKill;
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(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<spv::Decoration>(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<Target> 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<spv::StorageClass>(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<uint32_t> 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<spv::Decoration>(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<int32_t>(memberIndex));
const uint32_t floatTy = FloatType(ctx);
uint32_t entryFuncId = 0;
for (Instruction& ep : ctx->module()->entry_points()) {
// OpEntryPoint <model> <function> "name" <interface...>
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<Instruction>(
ctx, spv::Op::OpAccessChain, ptrOutV4, posPtrId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {blockVarId}},
{SPV_OPERAND_TYPE_ID, {memberConst}}}));
const uint32_t posId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, v4floatTypeId, posId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {posPtrId}}}));
const uint32_t wId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpCompositeExtract, floatTy, wId,
std::initializer_list<Operand>{{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<Instruction>(
ctx, spv::Op::OpLoad, t.typeId, valId,
std::initializer_list<Operand>{
{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<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{
{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<Instruction*> 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<Instruction>(
ctx, spv::Op::OpLoad, loadType, origId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {ptrId}}}));
const uint32_t fcId = ctx->TakeNextId();
load->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, v4floatTypeId, fcId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {fragCoordId}}}));
const uint32_t wId = ctx->TakeNextId();
load->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpCompositeExtract, floatTy, wId,
std::initializer_list<Operand>{{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<EmulateNoPerspectivePass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 <Includes.h>
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