diff --git a/CMakeLists.txt b/CMakeLists.txt index 19b1f6ce..75b64a94 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,6 +411,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DeferGeometryEmitsPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index 7c9a08b6..4e87aa76 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -23,6 +23,7 @@ add_executable( StripCompatPerVertexMembersTest.cpp LowerViewportIndexTest.cpp DeferGeometryEmitsTest.cpp + RetainGeometryOutputsTest.cpp ClampMultisampleFetchTest.cpp LegalizeResourceArrayIndexTest.cpp FlattenAtomicCounterBlockTest.cpp @@ -46,6 +47,7 @@ target_include_directories(SpirvPassTest PRIVATE target_link_libraries( SpirvPassTest PRIVATE MobileGLTestMain + glslang-default-resource-limits ${LINK_LIBRARIES} ) diff --git a/MobileGL/MG_Test/ShaderTranspiler/RetainGeometryOutputsTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/RetainGeometryOutputsTest.cpp new file mode 100644 index 00000000..57fbd7a3 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/RetainGeometryOutputsTest.cpp @@ -0,0 +1,503 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/RetainGeometryOutputsTest.cpp +// Copyright (c) 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 +// +// RetainGeometryOutputsPass makes a geometry output keep its last written value across +// EmitVertex(), as desktop drivers do. What has to hold: an output written before an earlier emit +// is copied from its Private shadow into the real output right before every emit and is otherwise +// never touched directly; outputs written before every emit, and stages that write everything +// before every emit, stay as they were; unwritten builtin members stay inactive; the module stays +// valid Vulkan SPIR-V whose ESSL translation compiles; and the sanitize chain both backends consume +// runs it. + +#include + +#include +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "source/opt/build_module.h" +#include "source/opt/ir_context.h" +#include "spirv.hpp" + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::RetainGeometryOutputsPass; +using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; +using MobileGL::MG_Util::ShaderTranspiler::SpvcSession; +using spvtools::opt::Instruction; +using spvtools::opt::IRContext; + +namespace { + Vector CompileStage(GLenum stage, const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult || binaryResult->empty()) return {}; + return binaryResult->front(); + } + + Vector CompileGeometry(const String& source) { + return CompileStage(GL_GEOMETRY_SHADER, source); + } + + Vector Retain(const Vector& spirv) { + spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass()); + spvtools::OptimizerOptions options; + options.set_run_validator(false); + Vector out; + EXPECT_TRUE(optimizer.Run(spirv.data(), spirv.size(), &out, options)); + return out; + } + + Bool Validates(const Vector& spirv, String& messages) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + tools.SetMessageConsumer([&messages](spv_message_level_t, const char*, const spv_position_t&, + const char* message) { messages += String(message) + "\n"; }); + return tools.Validate(spirv); + } + + String Transpile(const Vector& spirv) { + SpvcSession session(spirv, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log); + return essl ? essl.value() : String{}; + } + + // Whether the ESSL SPIRV-Cross printed is accepted by an ES front end. + Bool EsslCompiles(const String& essl, String& log) { + glslang::TShader shader(EShLangGeometry); + const char* strings[] = {essl.c_str()}; + shader.setStrings(strings, 1); + const Bool ok = shader.parse(GetDefaultResources(), 320, EEsProfile, false, false, EShMsgDefault); + log = shader.getInfoLog(); + return ok; + } + + std::unique_ptr Parse(const Vector& spirv) { + return spvtools::BuildModule(SPV_ENV_VULKAN_1_1, nullptr, spirv.data(), spirv.size()); + } + + // The OpVariable whose OpName is `name`, or 0. + Uint32 VariableNamed(IRContext* context, const String& name) { + for (const Instruction& debugInst : context->debugs2()) { + if (debugInst.opcode() != spv::Op::OpName || debugInst.GetInOperand(1).AsString() != name) continue; + const Instruction* def = context->get_def_use_mgr()->GetDef(debugInst.GetSingleWordInOperand(0)); + if (def != nullptr && def->opcode() == spv::Op::OpVariable) return def->result_id(); + } + return 0; + } + + spv::StorageClass StorageClassOf(IRContext* context, Uint32 variableId) { + return static_cast( + context->get_def_use_mgr()->GetDef(variableId)->GetSingleWordInOperand(0)); + } + + // Whether `pointerId` addresses the whole of `variableId` (member < 0) or exactly its member. + Bool Addresses(IRContext* context, Uint32 pointerId, Uint32 variableId, Int member) { + if (member < 0) return pointerId == variableId; + const Instruction* chain = context->get_def_use_mgr()->GetDef(pointerId); + if (chain == nullptr || chain->opcode() != spv::Op::OpAccessChain || chain->NumInOperands() != 2 || + chain->GetSingleWordInOperand(0) != variableId) + return false; + const Instruction* index = context->get_def_use_mgr()->GetDef(chain->GetSingleWordInOperand(1)); + return index != nullptr && index->opcode() == spv::Op::OpConstant && + index->GetSingleWordInOperand(0) == Uint32(member); + } + + struct EmitSites { + SizeT emits = 0; + // Emits whose own straight-line run (since the previous emit or the block start) stores the output. + SizeT emitsPrecededByStore = 0; + SizeT storesToOutput = 0; + SizeT loadsFromOutput = 0; + }; + + EmitSites Inspect(IRContext* context, Uint32 variableId, Int member = -1) { + EmitSites sites; + for (auto& function : *context->module()) { + for (auto& block : function) { + Bool storedSinceEmit = false; + for (Instruction& inst : block) { + if (inst.opcode() == spv::Op::OpEmitVertex || inst.opcode() == spv::Op::OpEmitStreamVertex) { + ++sites.emits; + if (storedSinceEmit) ++sites.emitsPrecededByStore; + storedSinceEmit = false; + } else if (inst.opcode() == spv::Op::OpStore && + Addresses(context, inst.GetSingleWordInOperand(0), variableId, member)) { + ++sites.storesToOutput; + storedSinceEmit = true; + } else if (inst.opcode() == spv::Op::OpLoad && + Addresses(context, inst.GetSingleWordInOperand(0), variableId, member)) { + ++sites.loadsFromOutput; + } + } + } + } + return sites; + } + + Uint32 PerVertexBlock(IRContext* context) { + for (const Instruction& global : context->types_values()) { + if (global.opcode() != spv::Op::OpVariable || + static_cast(global.GetSingleWordInOperand(0)) != spv::StorageClass::Output) + continue; + const Instruction* pointer = context->get_def_use_mgr()->GetDef(global.type_id()); + const Instruction* pointee = context->get_def_use_mgr()->GetDef(pointer->GetSingleWordInOperand(1)); + if (pointee->opcode() == spv::Op::OpTypeStruct) return global.result_id(); + } + return 0; + } + + void ExpectValidAndCompilable(const Vector& spirv) { + String messages; + EXPECT_TRUE(Validates(spirv, messages)) << messages; + const String essl = Transpile(spirv); + ASSERT_FALSE(essl.empty()); + String log; + EXPECT_TRUE(EsslCompiles(essl, log)) << log << "\n" << essl; + } + + // gsprobe's case 3 and iterationT's gbuffers_terrain.gsh: a flat output written (and read + // back) once, before the loop that emits; the provoking vertex is the third. + const char* const kFlatBeforeLoop = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +in vec2 v_pos[]; +flat out float res; +out vec2 p; +void main() { + res = abs(v_pos[0].x) + 1.0; + res = exp2(round(log2(res))); + for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; p = v_pos[i]; EmitVertex(); } + EndPrimitive(); +} +)"; + + // Everything written before every emit, one output through both arms of a branch. + const char* const kWritesEverythingPerEmit = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +in vec2 v_pos[]; +flat out float res; +out vec2 p; +void main() { + for (int i = 0; i < 3; i++) { + if (v_pos[i].x > 0.0) res = 1.0; else res = 2.0; + gl_Position = gl_in[i].gl_Position; + p = v_pos[i]; + EmitVertex(); + } + EndPrimitive(); +} +)"; + + // gl_PointSize written once in a gl_PerVertex that also declares gl_ClipDistance, plus the + // layered-rendering idiom of a gl_Layer set before the emit loop. + const char* const kBuiltinsBeforeLoop = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +void main() { + gl_PointSize = 4.0; + gl_Layer = 2; + for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; EmitVertex(); } +} +)"; + + // The emit lives in a helper; main writes the flat output once and calls it per vertex. + const char* const kEmitInHelper = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +flat out float res; +void emitCorner(int i) { gl_Position = gl_in[i].gl_Position; EmitVertex(); } +void main() { + res = 42.0; + emitCorner(0); + emitCorner(1); + emitCorner(2); +} +)"; + + // Fully written before every emit, but from its own value before the previous emit. + const char* const kReadBackAfterEmit = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +flat out float res; +void main() { + res = 1.0; + for (int i = 0; i < 3; i++) { res = res * 2.0; gl_Position = gl_in[i].gl_Position; EmitVertex(); } +} +)"; + + // A partially written output: .w once, .xyz per vertex. + const char* const kPartialWrites = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +out vec4 c; +void main() { + c.w = 0.25; + for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; c.xyz = vec3(float(i)); EmitVertex(); } +} +)"; + + // A user output block: only the member written before the loop is carried. + const char* const kUserBlock = R"(#version 410 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +out Data { flat float res; vec2 p; } vOut; +void main() { + vOut.res = 42.0; + for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; vOut.p = vec2(float(i)); EmitVertex(); } +} +)"; + + const char* const kStreams = R"(#version 410 core +layout(points) in; +layout(points, max_vertices = 4) out; +layout(stream = 0) out float a; +layout(stream = 1) out float b; +void main() { + a = 1.0; + b = 2.0; + for (int i = 0; i < 2; i++) { + gl_Position = gl_in[0].gl_Position; + EmitStreamVertex(0); + EmitStreamVertex(1); + } +} +)"; + + // The flat-before-loop shape plus a storage write after the emits, so DeferGeometryEmitsPass + // has something to defer. + const char* const kFlatBeforeLoopWithMemoryWrite = R"(#version 430 core +layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +layout(std430, binding = 0) buffer Counter { uint n; }; +flat out float res; +void main() { + res = 42.0; + for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; EmitVertex(); } + atomicAdd(n, 1u); +} +)"; + + const char* const kVertex = R"(#version 410 core +out float res; +void main() { res = 1.0; gl_Position = vec4(0.0); } +)"; +} // namespace + +TEST(RetainGeometryOutputs, AFlatOutputWrittenBeforeTheLoopIsCopiedBeforeEveryEmit) { + const Vector spirv = CompileGeometry(kFlatBeforeLoop); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + ExpectValidAndCompilable(out); + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + const Uint32 res = VariableNamed(context.get(), "res"); + const Uint32 shadow = VariableNamed(context.get(), "mg_retained_res"); + ASSERT_NE(res, 0u); + ASSERT_NE(shadow, 0u); + EXPECT_EQ(StorageClassOf(context.get(), res), spv::StorageClass::Output); + EXPECT_EQ(StorageClassOf(context.get(), shadow), spv::StorageClass::Private); + + // The real output is written only by the copy in front of the one emit, and never read. + const EmitSites atOutput = Inspect(context.get(), res); + EXPECT_EQ(atOutput.emits, 1u); + EXPECT_EQ(atOutput.emitsPrecededByStore, 1u); + EXPECT_EQ(atOutput.storesToOutput, 1u); + EXPECT_EQ(atOutput.loadsFromOutput, 0u); + // The shader's own two writes and its read-back moved to the shadow. + const EmitSites atShadow = Inspect(context.get(), shadow); + EXPECT_EQ(atShadow.storesToOutput, 2u); + EXPECT_GE(atShadow.loadsFromOutput, 2u); + + // `p` is written before every emit, so it is not carried. + EXPECT_EQ(VariableNamed(context.get(), "mg_retained_p"), 0u); + EXPECT_EQ(VariableNamed(context.get(), "mg_retained_Position"), 0u); +} + +TEST(RetainGeometryOutputs, AStageThatWritesEveryOutputBeforeEveryEmitIsLeftAlone) { + const Vector spirv = CompileGeometry(kWritesEverythingPerEmit); + ASSERT_FALSE(spirv.empty()); + EXPECT_EQ(Retain(spirv), spirv); +} + +TEST(RetainGeometryOutputs, ANonGeometryStageIsLeftAlone) { + const Vector spirv = CompileStage(GL_VERTEX_SHADER, kVertex); + ASSERT_FALSE(spirv.empty()); + EXPECT_EQ(Retain(spirv), spirv); +} + +TEST(RetainGeometryOutputs, OnlyTheWrittenBuiltinMembersAreCarried) { + const Vector spirv = CompileGeometry(kBuiltinsBeforeLoop); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + String messages; + EXPECT_TRUE(Validates(out, messages)) << messages; + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + EXPECT_NE(VariableNamed(context.get(), "mg_retained_PointSize"), 0u); + EXPECT_NE(VariableNamed(context.get(), "mg_retained_Layer"), 0u); + EXPECT_EQ(VariableNamed(context.get(), "mg_retained_Position"), 0u); + EXPECT_EQ(VariableNamed(context.get(), "mg_retained_ClipDistance"), 0u); + + const Uint32 perVertex = PerVertexBlock(context.get()); + ASSERT_NE(perVertex, 0u); + const EmitSites pointSize = Inspect(context.get(), perVertex, 1); + EXPECT_EQ(pointSize.emitsPrecededByStore, pointSize.emits); + EXPECT_EQ(pointSize.storesToOutput, pointSize.emits); + const EmitSites layer = Inspect(context.get(), VariableNamed(context.get(), "gl_Layer")); + EXPECT_EQ(layer.emitsPrecededByStore, layer.emits); + EXPECT_EQ(layer.storesToOutput, layer.emits); + + const String essl = Transpile(out); + ASSERT_FALSE(essl.empty()); + EXPECT_EQ(essl.find("gl_ClipDistance"), String::npos) << "an unwritten builtin member became active\n" << essl; + EXPECT_NE(essl.find("gl_Layer = "), String::npos) << essl; +} + +TEST(RetainGeometryOutputs, OnlyTheWrittenUserBlockMemberIsCarried) { + const Vector spirv = CompileGeometry(kUserBlock); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + ExpectValidAndCompilable(out); + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + EXPECT_NE(VariableNamed(context.get(), "mg_retained_res"), 0u); + EXPECT_EQ(VariableNamed(context.get(), "mg_retained_p"), 0u); + const Uint32 block = VariableNamed(context.get(), "vOut"); + ASSERT_NE(block, 0u); + const EmitSites res = Inspect(context.get(), block, 0); + EXPECT_EQ(res.emitsPrecededByStore, res.emits); + EXPECT_EQ(res.storesToOutput, res.emits); +} + +TEST(RetainGeometryOutputs, AnEmitInsideAHelperGetsTheCopyToo) { + const Vector spirv = CompileGeometry(kEmitInHelper); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + ExpectValidAndCompilable(out); + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res")); + EXPECT_EQ(sites.emits, 1u); + EXPECT_EQ(sites.emitsPrecededByStore, 1u); + EXPECT_EQ(sites.storesToOutput, 1u); +} + +TEST(RetainGeometryOutputs, AnOutputReadBackAfterAnEmitIsCarried) { + const Vector spirv = CompileGeometry(kReadBackAfterEmit); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + ExpectValidAndCompilable(out); + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res")); + EXPECT_EQ(sites.storesToOutput, sites.emits); + EXPECT_EQ(sites.loadsFromOutput, 0u) << "the read-back must see the shadow, not the emitted output"; +} + +TEST(RetainGeometryOutputs, APartiallyWrittenOutputIsCarriedWhole) { + const Vector spirv = CompileGeometry(kPartialWrites); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + ExpectValidAndCompilable(out); + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "c")); + EXPECT_EQ(sites.emitsPrecededByStore, sites.emits); + EXPECT_EQ(sites.storesToOutput, sites.emits); + EXPECT_NE(VariableNamed(context.get(), "mg_retained_c"), 0u); +} + +TEST(RetainGeometryOutputs, StreamEmitsGetTheCopyToo) { + const Vector spirv = CompileGeometry(kStreams); + ASSERT_FALSE(spirv.empty()); + const Vector out = Retain(spirv); + ASSERT_NE(out, spirv); + String messages; + EXPECT_TRUE(Validates(out, messages)) << messages; + + auto context = Parse(out); + ASSERT_NE(context, nullptr); + for (const char* name : {"a", "b"}) { + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), name)); + EXPECT_EQ(sites.emits, 2u) << name; + EXPECT_EQ(sites.emitsPrecededByStore, 2u) << name; + EXPECT_EQ(sites.storesToOutput, 2u) << name; + } +} + +TEST(RetainGeometryOutputs, TheDeferredEmitReplayCarriesTheRetainedValue) { + const Vector spirv = CompileGeometry(kFlatBeforeLoopWithMemoryWrite); + ASSERT_FALSE(spirv.empty()); + const Vector retained = Retain(spirv); + ASSERT_NE(retained, spirv); + Vector deferredSpirv; + bool deferred = false; + ASSERT_TRUE(ShaderCompiler::DeferGeometryEmitsForEssl(retained, deferredSpirv, deferred, true)); + ASSERT_TRUE(deferred); + ExpectValidAndCompilable(deferredSpirv); + + // The snapshot taken at each recorded emit reads `res` after the copy, so the replay restores it. + auto context = Parse(deferredSpirv); + ASSERT_NE(context, nullptr); + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res")); + EXPECT_EQ(sites.emits, 1u); + EXPECT_EQ(sites.emitsPrecededByStore, 1u); + EXPECT_GE(sites.loadsFromOutput, 1u); +} + +TEST(RetainGeometryOutputs, TheSanitizeChainBothBackendsConsumeRunsIt) { + const Vector spirv = CompileGeometry(kFlatBeforeLoop); + ASSERT_FALSE(spirv.empty()); + Vector sanitized; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(spirv, sanitized, true, true)); + ExpectValidAndCompilable(sanitized); + + auto context = Parse(sanitized); + ASSERT_NE(context, nullptr); + ASSERT_NE(VariableNamed(context.get(), "mg_retained_res"), 0u); + const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res")); + EXPECT_EQ(sites.emitsPrecededByStore, sites.emits); + EXPECT_EQ(sites.storesToOutput, sites.emits); +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index ca4742bc..a40d82ca 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -52,6 +52,7 @@ #include "SpirvPasses/StripUniformLocationsPass.h" #include "SpirvPasses/StripIoBlockLocationsPass.h" #include "SpirvPasses/DeferGeometryEmitsPass.h" +#include "SpirvPasses/RetainGeometryOutputsPass.h" #include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h" #include "SpirvPasses/StripNoPerspectivePass.h" #include "SpirvPasses/EmulateNoPerspectivePass.h" @@ -1375,6 +1376,12 @@ namespace MobileGL { RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass()); optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass()); optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass()); + // Geometry outputs keep their last written value across EmitVertex(), as every + // desktop driver keeps them; here rather than in a backend so both backends, and + // every pass after this chain that works at emit time, see the retained values. + // A non-geometry module, or one that writes every output before every emit, is + // returned unchanged. + optimizer.RegisterPass(RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass()); // The fp64 tail, and the ONE part of this chain that is not the same on every // backend. Both passes are skipped when the backend can consume Float64 itself // (`nativeFloat64`, i.e. VkPhysicalDeviceFeatures::shaderFloat64 on DirectVulkan): diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp new file mode 100644 index 00000000..866babe5 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp @@ -0,0 +1,513 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp +// Copyright (c) 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 "RetainGeometryOutputsPass.h" + +#include "spirv.hpp" +#include "source/opt/basic_block.h" +#include "source/opt/cfg.h" +#include "source/opt/constants.h" +#include "source/opt/decoration_manager.h" +#include "source/opt/def_use_manager.h" +#include "source/opt/function.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/util/make_unique.h" +#include "source/util/string_utils.h" + +#include +#include +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::BasicBlock; + using spvtools::opt::Function; + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + using OperandList = std::vector; + // One bit per slot: whether the slot was fully written since the last emit. + using WrittenSet = std::vector; + + Operand Id(uint32_t id) { + return {SPV_OPERAND_TYPE_ID, {id}}; + } + + std::unique_ptr Make(IRContext* context, spv::Op op, uint32_t typeId, uint32_t resultId, + OperandList operands) { + return spvtools::MakeUnique(context, op, typeId, resultId, operands); + } + + Bool IsEmit(spv::Op op) { + return op == spv::Op::OpEmitVertex || op == spv::Op::OpEmitStreamVertex; + } + + Bool IsAccessChain(spv::Op op) { + return op == spv::Op::OpAccessChain || op == spv::Op::OpInBoundsAccessChain; + } + + // Users that name a variable without touching its memory. + Bool IsDeclarationUser(spv::Op op) { + return op == spv::Op::OpName || op == spv::Op::OpDecorate || op == spv::Op::OpDecorateId || + op == spv::Op::OpDecorateString || op == spv::Op::OpEntryPoint || + op == spv::Op::OpGroupDecorate; + } + + // The value of an OpConstant integer, or nullopt for anything else. + Optional ConstantValue(IRContext* context, uint32_t id) { + const Instruction* def = context->get_def_use_mgr()->GetDef(id); + if (def == nullptr || def->opcode() != spv::Op::OpConstant || def->NumInOperands() < 1) + return std::nullopt; + return def->GetSingleWordInOperand(0); + } + + Bool IsBlockStruct(IRContext* context, uint32_t typeId) { + const Instruction* type = context->get_def_use_mgr()->GetDef(typeId); + return type != nullptr && type->opcode() == spv::Op::OpTypeStruct && + context->get_decoration_mgr()->HasDecoration(typeId, uint32_t(spv::Decoration::Block)); + } + + // Whether a Block-decorated struct sits anywhere inside this type: such a type + // cannot be copied into a Private shadow. + Bool ContainsBlock(IRContext* context, uint32_t typeId) { + const Instruction* type = context->get_def_use_mgr()->GetDef(typeId); + if (type == nullptr) return false; + switch (type->opcode()) { + case spv::Op::OpTypeArray: + case spv::Op::OpTypeRuntimeArray: + return ContainsBlock(context, type->GetSingleWordInOperand(0)); + case spv::Op::OpTypeStruct: + if (IsBlockStruct(context, typeId)) return true; + for (uint32_t i = 0; i < type->NumInOperands(); ++i) { + if (ContainsBlock(context, type->GetSingleWordInOperand(i))) return true; + } + return false; + default: + return false; + } + } + + // Whether every use of this pointer, followed through access chains, is one the + // redirection can retarget (loads, stores, copies, further access chains), whether + // any of them writes, and every pointer derived from it. A pointer handed to a call + // or used as a value is not redirectable: the callee's parameter type names the + // Output storage class. + struct PointerUse { + Bool redirectable = true; + Bool stored = false; + std::vector pointers; + }; + void ScanPointerUses(IRContext* context, Instruction* pointer, PointerUse& use) { + use.pointers.push_back(pointer->result_id()); + context->get_def_use_mgr()->ForEachUse(pointer, [&](Instruction* user, uint32_t operandIndex) { + const spv::Op op = user->opcode(); + if (IsDeclarationUser(op)) return; + switch (op) { + case spv::Op::OpLoad: + break; + case spv::Op::OpStore: + if (operandIndex == 0) { + use.stored = true; + } else { + use.redirectable = false; + } + break; + case spv::Op::OpCopyMemory: + case spv::Op::OpCopyMemorySized: + if (operandIndex == 0) use.stored = true; + break; + case spv::Op::OpAccessChain: + case spv::Op::OpInBoundsAccessChain: + if (operandIndex == 2) { + ScanPointerUses(context, user, use); + } else { + use.redirectable = false; + } + break; + default: + use.redirectable = false; + break; + } + }); + } + + // One output value that may be retained: a whole output variable, or one member + // of an output block. + struct Slot { + uint32_t variableId = 0; + Int64 member = -1; + uint32_t valueTypeId = 0; + uint32_t blockTypeId = 0; + // Filled for the slots that are retained. + uint32_t shadowId = 0; + uint32_t privatePointerTypeId = 0; + uint32_t outputPointerTypeId = 0; + uint32_t memberIndexId = 0; + }; + + String NameOf(IRContext* context, uint32_t id) { + for (const Instruction& debugInst : context->debugs2()) { + if (debugInst.opcode() == spv::Op::OpName && debugInst.GetSingleWordInOperand(0) == id) + return debugInst.GetInOperand(1).AsString(); + } + return {}; + } + + String MemberNameOf(IRContext* context, uint32_t structTypeId, uint32_t member) { + for (const Instruction& debugInst : context->debugs2()) { + if (debugInst.opcode() == spv::Op::OpMemberName && + debugInst.GetSingleWordInOperand(0) == structTypeId && + debugInst.GetSingleWordInOperand(1) == member) + return debugInst.GetInOperand(2).AsString(); + } + return {}; + } + + // The shadow's debug name. A builtin's `gl_` prefix is dropped so the generated + // source never spells a reserved name, nor one a text pass searches for. + String ShadowName(String outputName) { + if (outputName.empty()) return {}; + if (outputName.rfind("gl_", 0) == 0) outputName.erase(0, 3); + while (!outputName.empty() && outputName.front() == '_') + outputName.erase(0, 1); + return outputName.empty() ? String{} : "mg_retained_" + outputName; + } + + // Which slot each pointer addresses: `whole` holds the pointers a store through + // which rewrites the whole slot, `any` every pointer into a slot. + struct SlotPointers { + std::unordered_map whole; + std::unordered_map any; + }; + + // Runs a must-be-written dataflow over one function and marks every slot whose + // value from before an earlier emit can be observed: at an emit (not fully written + // since the previous one) or by a read (same condition). The entry function starts + // with every slot counted as written, as nothing has been emitted yet; any other + // function starts with none, since its caller may have emitted. A call that can + // emit acts like an emit; writes inside callees are not followed. + void MarkStaleSlots(IRContext* context, Function& function, Bool isEntryFunction, + const SlotPointers& slotPointers, + const std::unordered_set& emittingFunctions, SizeT slotCount, + std::vector& stale) { + const WrittenSet all(slotCount, true); + const WrittenSet none(slotCount, false); + auto transfer = [&](BasicBlock& block, WrittenSet& written, Bool check) { + auto read = [&](uint32_t pointer) { + const auto slot = slotPointers.any.find(pointer); + if (check && slot != slotPointers.any.end() && !written[slot->second]) + stale[slot->second] = true; + }; + for (Instruction& inst : block) { + const spv::Op op = inst.opcode(); + if (IsEmit(op)) { + if (check) { + for (SizeT slot = 0; slot < slotCount; ++slot) { + if (!written[slot]) stale[slot] = true; + } + } + written = none; + } else if (op == spv::Op::OpFunctionCall) { + if (emittingFunctions.count(inst.GetSingleWordInOperand(0)) != 0) written = none; + } else if (op == spv::Op::OpLoad) { + read(inst.GetSingleWordInOperand(0)); + } else if (op == spv::Op::OpStore || op == spv::Op::OpCopyMemory || + op == spv::Op::OpCopyMemorySized) { + if (op != spv::Op::OpStore) read(inst.GetSingleWordInOperand(1)); + const auto target = slotPointers.whole.find(inst.GetSingleWordInOperand(0)); + if (target != slotPointers.whole.end()) written[target->second] = true; + } + } + }; + + spvtools::opt::CFG* cfg = context->cfg(); + const uint32_t entryLabel = function.begin()->id(); + std::unordered_map out; + for (BasicBlock& block : function) + out[block.id()] = all; + auto entryState = [&](BasicBlock& block) { + if (block.id() == entryLabel) return isEntryFunction ? all : none; + WrittenSet written = all; + for (uint32_t predecessor : cfg->preds(block.id())) { + const auto found = out.find(predecessor); + if (found == out.end()) continue; + for (SizeT slot = 0; slot < slotCount; ++slot) + written[slot] = written[slot] && found->second[slot]; + } + return written; + }; + for (Bool changed = true; changed;) { + changed = false; + for (BasicBlock& block : function) { + WrittenSet written = entryState(block); + transfer(block, written, false); + if (written != out[block.id()]) { + out[block.id()] = std::move(written); + changed = true; + } + } + } + for (BasicBlock& block : function) { + WrittenSet written = entryState(block); + transfer(block, written, true); + } + } + } // namespace + + spvtools::opt::Pass::Status RetainGeometryOutputsPass::Process() { + IRContext* irContext = context(); + auto* defUse = irContext->get_def_use_mgr(); + + // One geometry entry point. + Instruction* entryPoint = nullptr; + for (Instruction& candidate : irContext->module()->entry_points()) { + if (entryPoint != nullptr) return Status::SuccessWithoutChange; + entryPoint = &candidate; + } + if (entryPoint == nullptr || static_cast(entryPoint->GetSingleWordInOperand(0)) != + spv::ExecutionModel::Geometry) { + return Status::SuccessWithoutChange; + } + const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1); + + // Every emit, and every function a call to which can emit. + std::vector emits; + std::unordered_set emittingFunctions; + for (Function& function : *irContext->module()) { + function.ForEachInst([&](Instruction* inst) { + if (!IsEmit(inst->opcode())) return; + emits.push_back(inst); + emittingFunctions.insert(function.result_id()); + }); + } + if (emits.empty()) return Status::SuccessWithoutChange; + for (Bool grew = true; grew;) { + grew = false; + for (Function& function : *irContext->module()) { + if (emittingFunctions.count(function.result_id()) != 0) continue; + Bool callsEmitter = false; + function.ForEachInst([&](Instruction* inst) { + if (inst->opcode() == spv::Op::OpFunctionCall && + emittingFunctions.count(inst->GetSingleWordInOperand(0)) != 0) + callsEmitter = true; + }); + if (callsEmitter) { + emittingFunctions.insert(function.result_id()); + grew = true; + } + } + } + + // The candidate slots: every output, or output block member, the shader writes and + // whose every use can be redirected. + std::vector slots; + SlotPointers slotPointers; + for (Instruction& global : irContext->types_values()) { + if (global.opcode() != spv::Op::OpVariable || + static_cast(global.GetSingleWordInOperand(0)) != spv::StorageClass::Output) { + continue; + } + const uint32_t valueTypeId = defUse->GetDef(global.type_id())->GetSingleWordInOperand(1); + if (!IsBlockStruct(irContext, valueTypeId)) { + if (ContainsBlock(irContext, valueTypeId)) continue; + PointerUse use; + ScanPointerUses(irContext, &global, use); + if (!use.stored || !use.redirectable) continue; + slotPointers.whole[global.result_id()] = slots.size(); + for (uint32_t pointer : use.pointers) + slotPointers.any[pointer] = slots.size(); + slots.push_back({global.result_id(), -1, valueTypeId}); + continue; + } + + // A block, member by member. A use of the block as a whole (never emitted by + // the front end) leaves the whole block alone. + const Instruction* blockType = defUse->GetDef(valueTypeId); + const uint32_t memberCount = blockType->NumInOperands(); + std::vector memberUses(memberCount); + std::vector> memberWholePointers(memberCount); + Bool wholeBlockUse = false; + defUse->ForEachUse(&global, [&](Instruction* user, uint32_t operandIndex) { + if (IsDeclarationUser(user->opcode())) return; + if (!IsAccessChain(user->opcode()) || operandIndex != 2 || user->NumInOperands() < 2) { + wholeBlockUse = true; + return; + } + const auto member = ConstantValue(irContext, user->GetSingleWordInOperand(1)); + if (!member || *member >= memberCount) { + wholeBlockUse = true; + return; + } + ScanPointerUses(irContext, user, memberUses[*member]); + if (user->NumInOperands() == 2) memberWholePointers[*member].push_back(user->result_id()); + }); + if (wholeBlockUse) continue; + for (uint32_t member = 0; member < memberCount; ++member) { + if (!memberUses[member].stored || !memberUses[member].redirectable) continue; + for (uint32_t pointer : memberWholePointers[member]) + slotPointers.whole[pointer] = slots.size(); + for (uint32_t pointer : memberUses[member].pointers) + slotPointers.any[pointer] = slots.size(); + slots.push_back({global.result_id(), Int64(member), blockType->GetSingleWordInOperand(member), + valueTypeId}); + } + } + if (slots.empty()) return Status::SuccessWithoutChange; + + // Which slots can be observed holding a value from before an earlier emit. + std::vector stale(slots.size(), false); + for (Function& function : *irContext->module()) { + MarkStaleSlots(irContext, function, function.result_id() == entryFunctionId, slotPointers, + emittingFunctions, slots.size(), stale); + } + std::vector retained; + for (SizeT slot = 0; slot < slots.size(); ++slot) { + if (stale[slot]) retained.push_back(slots[slot]); + } + if (retained.empty()) return Status::SuccessWithoutChange; + + // ---- Types and constants, then the shadows, before any instruction changes ---- + auto* typeMgr = irContext->get_type_mgr(); + auto* constMgr = irContext->get_constant_mgr(); + for (Slot& slot : retained) { + slot.privatePointerTypeId = + typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Private); + if (slot.member >= 0) { + slot.outputPointerTypeId = + typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Output); + slot.memberIndexId = constMgr->GetUIntConstId(uint32_t(slot.member)); + } + } + + // Every access chain derived from a redirected pointer changes storage class with + // it; collected (with the Private pointer type it needs) while def-use is intact. + std::vector> retypes; + std::unordered_set retypeSeen; + auto collectRetypes = [&](auto&& self, Instruction* pointer) -> void { + defUse->ForEachUser(pointer, [&](Instruction* user) { + if (!IsAccessChain(user->opcode()) || user->GetSingleWordInOperand(0) != pointer->result_id()) + return; + if (!retypeSeen.insert(user).second) return; + const uint32_t pointeeTypeId = defUse->GetDef(user->type_id())->GetSingleWordInOperand(1); + retypes.emplace_back(user, + typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Private)); + self(self, user); + }); + }; + + // Operand rewrites: (instruction, operand index counting result type and id, new id). + struct Rewrite { + Instruction* inst; + uint32_t operand; + uint32_t newId; + }; + std::vector rewrites; + // Single-index chains onto a retained block member: the shadow replaces them. + std::vector aliasChains; + // Deeper chains onto a retained block member: rebased onto the shadow. + std::vector> rebasedChains; + + for (Slot& slot : retained) { + slot.shadowId = irContext->TakeNextId(); + Instruction* variable = defUse->GetDef(slot.variableId); + if (slot.member < 0) { + defUse->ForEachUse(variable, [&](Instruction* user, uint32_t operandIndex) { + if (!IsDeclarationUser(user->opcode())) + rewrites.push_back({user, operandIndex, slot.shadowId}); + }); + collectRetypes(collectRetypes, variable); + continue; + } + defUse->ForEachUser(variable, [&](Instruction* user) { + if (!IsAccessChain(user->opcode()) || user->NumInOperands() < 2) return; + const auto member = ConstantValue(irContext, user->GetSingleWordInOperand(1)); + if (!member || Int64(*member) != slot.member) return; + if (user->NumInOperands() == 2) { + aliasChains.push_back(user); + defUse->ForEachUse(user, [&](Instruction* aliasUser, uint32_t operandIndex) { + if (!IsDeclarationUser(aliasUser->opcode())) + rewrites.push_back({aliasUser, operandIndex, slot.shadowId}); + }); + } else { + rebasedChains.emplace_back(user, &slot); + const uint32_t pointeeTypeId = defUse->GetDef(user->type_id())->GetSingleWordInOperand(1); + retypes.emplace_back(user, + typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Private)); + retypeSeen.insert(user); + } + collectRetypes(collectRetypes, user); + }); + } + + for (Slot& slot : retained) { + irContext->AddGlobalValue( + Make(irContext, spv::Op::OpVariable, slot.privatePointerTypeId, slot.shadowId, + {{SPV_OPERAND_TYPE_STORAGE_CLASS, {uint32_t(spv::StorageClass::Private)}}})); + const String outputName = slot.member < 0 + ? NameOf(irContext, slot.variableId) + : MemberNameOf(irContext, slot.blockTypeId, uint32_t(slot.member)); + const String shadowName = ShadowName(outputName); + if (!shadowName.empty()) { + irContext->AddDebug2Inst( + Make(irContext, spv::Op::OpName, 0, 0, + {Id(slot.shadowId), + {SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(shadowName.c_str())}})); + } + // From SPIR-V 1.4 the interface lists every global the entry point uses. + if (irContext->module()->version() >= 0x00010400u) entryPoint->AddOperand(Id(slot.shadowId)); + } + + // ---- Hand-built from here on; analyses are rebuilt once at the end ---------- + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + + for (const Rewrite& rewrite : rewrites) + rewrite.inst->SetOperand(rewrite.operand, {rewrite.newId}); + for (auto& [chain, slot] : rebasedChains) { + OperandList operands; + operands.push_back(Id(slot->shadowId)); + for (uint32_t i = 2; i < chain->NumInOperands(); ++i) + operands.push_back(chain->GetInOperand(i)); + chain->SetInOperands(std::move(operands)); + } + for (auto& [chain, typeId] : retypes) + chain->SetResultType(typeId); + for (Instruction* chain : aliasChains) + irContext->KillInst(chain); + + // The copy into the real outputs, immediately before every emit. + for (Instruction* emit : emits) { + for (const Slot& slot : retained) { + const uint32_t value = irContext->TakeNextId(); + emit->InsertBefore( + Make(irContext, spv::Op::OpLoad, slot.valueTypeId, value, {Id(slot.shadowId)})); + uint32_t target = slot.variableId; + if (slot.member >= 0) { + target = irContext->TakeNextId(); + emit->InsertBefore(Make(irContext, spv::Op::OpAccessChain, slot.outputPointerTypeId, target, + {Id(slot.variableId), Id(slot.memberIndexId)})); + } + emit->InsertBefore(Make(irContext, spv::Op::OpStore, 0, 0, {Id(target), Id(value)})); + } + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass() { + return spvtools::Optimizer::PassToken(spvtools::MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h new file mode 100644 index 00000000..fa4a5d97 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h @@ -0,0 +1,42 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h +// Copyright (c) 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 { + // Geometry outputs keep their last written value across EmitVertex(), as on every + // desktop GL driver. GLSL leaves them undefined after an emit, but shaders rely on the + // desktop behaviour: iterationT's terrain stage writes `flat out float + // textureResolution` once and then emits three vertices, and the flat value is taken + // from the LAST one (GL's default provoking vertex), which a driver that drops the + // value after the first emit renders as random black triangles. + // + // Each output whose value from before an EARLIER emit can be observed - at a later emit + // or by a read - gets a Private shadow; every load, store and access chain of it moves + // to the shadow, which is copied into the real output immediately before every emit + // (stream emits included). Block members are carried one by one, and only those the + // shader writes, so no unwritten builtin member becomes active. A must-be-written + // dataflow over each function's CFG finds them; a stage that writes every output + // before every emit is left byte-identical. Outputs whose pointer escapes into a call, + // and arrays of blocks, are left alone. + class RetainGeometryOutputsPass : public spvtools::opt::Pass { + public: + const char* name() const override { return "retain-geometry-outputs"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateRetainGeometryOutputsPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL