[Fix] (CMakeLists.txt, MG_Test/ShaderTranspiler, MG_Util/ShaderTranspiler): keep geometry outputs across EmitVertex

- RetainGeometryOutputsPass shadows each output whose pre-emit value can be
  observed later and copies it into the real output before every emit
- runs in SanitizeAndOptimizeBinary, so Magma and Espryt both get it
- iterationT terrain: flat textureResolution written once, emitted 3 times
This commit is contained in:
2026-10-18 15:55:05 -04:00
parent 0c2f8df727
commit c56c7e76c8
6 changed files with 1068 additions and 0 deletions
+1
View File
@@ -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
@@ -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}
)
@@ -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 <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <glslang/Public/ResourceLimits.h>
#include <glslang/Public/ShaderLang.h>
#include <spirv-tools/libspirv.hpp>
#include <spirv-tools/optimizer.hpp>
#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<Uint32> 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<Uint32> CompileGeometry(const String& source) {
return CompileStage(GL_GEOMETRY_SHADER, source);
}
Vector<Uint32> Retain(const Vector<Uint32>& spirv) {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass());
spvtools::OptimizerOptions options;
options.set_run_validator(false);
Vector<Uint32> out;
EXPECT_TRUE(optimizer.Run(spirv.data(), spirv.size(), &out, options));
return out;
}
Bool Validates(const Vector<Uint32>& 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<Uint32>& 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<IRContext> Parse(const Vector<Uint32>& 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<spv::StorageClass>(
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<spv::StorageClass>(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<Uint32>& 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<Uint32> spirv = CompileGeometry(kFlatBeforeLoop);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kWritesEverythingPerEmit);
ASSERT_FALSE(spirv.empty());
EXPECT_EQ(Retain(spirv), spirv);
}
TEST(RetainGeometryOutputs, ANonGeometryStageIsLeftAlone) {
const Vector<Uint32> spirv = CompileStage(GL_VERTEX_SHADER, kVertex);
ASSERT_FALSE(spirv.empty());
EXPECT_EQ(Retain(spirv), spirv);
}
TEST(RetainGeometryOutputs, OnlyTheWrittenBuiltinMembersAreCarried) {
const Vector<Uint32> spirv = CompileGeometry(kBuiltinsBeforeLoop);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kUserBlock);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kEmitInHelper);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kReadBackAfterEmit);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kPartialWrites);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kStreams);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> 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<Uint32> spirv = CompileGeometry(kFlatBeforeLoopWithMemoryWrite);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> retained = Retain(spirv);
ASSERT_NE(retained, spirv);
Vector<uint32_t> 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<Uint32> spirv = CompileGeometry(kFlatBeforeLoop);
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> 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);
}
@@ -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):
@@ -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 <memory>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<Operand>;
// One bit per slot: whether the slot was fully written since the last emit.
using WrittenSet = std::vector<Bool>;
Operand Id(uint32_t id) {
return {SPV_OPERAND_TYPE_ID, {id}};
}
std::unique_ptr<Instruction> Make(IRContext* context, spv::Op op, uint32_t typeId, uint32_t resultId,
OperandList operands) {
return spvtools::MakeUnique<Instruction>(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<uint32_t> 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<uint32_t> 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<uint32_t, SizeT> whole;
std::unordered_map<uint32_t, SizeT> 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<uint32_t>& emittingFunctions, SizeT slotCount,
std::vector<Bool>& 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<uint32_t, WrittenSet> 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<spv::ExecutionModel>(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<Instruction*> emits;
std::unordered_set<uint32_t> 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<Slot> slots;
SlotPointers slotPointers;
for (Instruction& global : irContext->types_values()) {
if (global.opcode() != spv::Op::OpVariable ||
static_cast<spv::StorageClass>(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<PointerUse> memberUses(memberCount);
std::vector<std::vector<uint32_t>> 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<Bool> stale(slots.size(), false);
for (Function& function : *irContext->module()) {
MarkStaleSlots(irContext, function, function.result_id() == entryFunctionId, slotPointers,
emittingFunctions, slots.size(), stale);
}
std::vector<Slot> 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<std::pair<Instruction*, uint32_t>> retypes;
std::unordered_set<Instruction*> 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<Rewrite> rewrites;
// Single-index chains onto a retained block member: the shadow replaces them.
std::vector<Instruction*> aliasChains;
// Deeper chains onto a retained block member: rebased onto the shadow.
std::vector<std::pair<Instruction*, Slot*>> 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<RetainGeometryOutputsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 <Includes.h>
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