[Fix] (Espryt): defer geometry emits behind memory writes on drivers that drop them

This commit is contained in:
2026-10-12 23:57:48 -04:00
parent 735b705397
commit cfbaffd757
16 changed files with 814 additions and 7 deletions
+1
View File
@@ -409,6 +409,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.cpp
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/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp
+7
View File
@@ -214,6 +214,13 @@ namespace MobileGL::MG_Config {
// lavapipe carry a located block correctly and would otherwise never run this code -
// and ForceOff is the negative control. See StripIoBlockLocationsPass.
QuirkOverride EsprytUnlocatedIoBlocks = QuirkOverride::Auto;
// MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS: DirectGLES moves a geometry shader's
// EmitVertex()/EndPrimitive() behind its memory writes (DeferGeometryEmitsPass). The
// Adreno ES driver silently drops storage writes a geometry invocation makes after
// emitting; the driver POST measures that and arms this by itself, so Auto is right
// everywhere. ForceOn exercises the rewrite on a healthy driver; ForceOff is the
// negative control that replays the lost writes.
QuirkOverride EsprytDeferGeometryEmits = QuirkOverride::Auto;
// MOBILEGL_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs
+1
View File
@@ -394,6 +394,7 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER");
features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS");
features.EsprytUnlocatedIoBlocks = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS");
features.EsprytDeferGeometryEmits = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS");
features.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
@@ -11629,6 +11629,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &loweredSpirv;
}
// A geometry stage that writes memory, on a driver that drops the writes it makes
// after EmitVertex(): every emit is deferred behind them (DeferGeometryEmitsPass). FIRST
// in the chain, while every output is still an Output variable - the viewport-index
// lowering below demotes one, and must see the replay's stores to it as well.
// Self-gated on the module (an emit and a memory write), so a geometry stage that
// writes nothing comes back untouched. Keyed by deferGeometryEmitsArmed.
Vector<unsigned int> deferredEmitSpirv;
if (glShaderType == GL_GEOMETRY_SHADER && g_GLESCapabilities.DropsGeometryWritesAfterEmit) {
bool deferred = false;
if (MG_Util::ShaderTranspiler::ShaderCompiler::DeferGeometryEmitsForEssl(
*effectiveSpirv, deferredEmitSpirv, deferred, enableSpirvValidation) &&
deferred && !deferredEmitSpirv.empty()) {
effectiveSpirv = &deferredEmitSpirv;
MGLOG_I_ONCE("DirectGLES is deferring a geometry stage's EmitVertex() calls behind its "
"memory writes, because this driver drops storage writes made after an emit.");
}
}
// ESSL cannot express gl_ViewportIndex either, but unlike the draw parameters
// there IS an extension that provides it - so this runs only when the driver does
// NOT advertise GL_OES_viewport_array. A driver that does keeps the builtin and
@@ -12641,6 +12659,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
(glShaderType == GL_VERTEX_SHADER || glShaderType == GL_TESS_EVALUATION_SHADER ||
glShaderType == GL_GEOMETRY_SHADER) &&
(ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray);
esslKeyInputs.deferGeometryEmitsArmed =
glShaderType == GL_GEOMETRY_SHADER && g_GLESCapabilities.DropsGeometryWritesAfterEmit;
esslKeyInputs.supportsNoperspectiveInterpolation =
g_GLESCapabilities.SupportsNoperspectiveInterpolation;
esslKeyInputs.supportsExtendedImageFormats =
@@ -13,6 +13,7 @@ add_executable(
UniquifyIoBlockNamesTest.cpp
StripIoBlockLocationsTest.cpp
LowerViewportIndexTest.cpp
DeferGeometryEmitsTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
@@ -0,0 +1,150 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DeferGeometryEmitsTest.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
//
// DeferGeometryEmitsPass moves a geometry stage's emits behind its memory writes for an ES driver
// that drops storage writes made after EmitVertex(). What has to hold: the rewritten module is
// valid, its ESSL calls the original body BEFORE the only EmitVertex() left (the replay), output
// members the shader never writes stay out of the ESSL, and a geometry stage that writes no memory
// is left alone.
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
Vector<Uint32> CompileGeometry(const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_GEOMETRY_SHADER, .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 = {GL_GEOMETRY_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
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{};
}
SizeT Count(const String& haystack, const String& needle) {
SizeT count = 0;
for (SizeT at = haystack.find(needle); at != String::npos; at = haystack.find(needle, at + 1)) ++count;
return count;
}
// KHR-GL4x.shader_storage_buffer_object.basic-atomic-case3's geometry stage: emit first, write after.
const char* const kWritesAfterEmit = R"(#version 430 core
layout(points) in;
layout(points, max_vertices = 1) out;
layout(std430, binding = 2) buffer GSBuffer { uint u[4]; } g_gs_buffer;
void main() {
gl_Position = gl_in[0].gl_Position;
gl_PrimitiveID = gl_PrimitiveIDIn;
EmitVertex();
if (atomicAdd(g_gs_buffer.u[gl_PrimitiveIDIn], 1u) != 0u) return;
atomicAdd(g_gs_buffer.u[0], 2u);
}
)";
// A strip with a cut in the middle and a user varying, so the replay has to carry a non-block
// output and reproduce EndPrimitive at the right vertex.
const char* const kStripWithCut = R"(#version 430 core
layout(points) in;
layout(triangle_strip, max_vertices = 6) out;
layout(std430, binding = 0) buffer Counter { uint n; };
out vec4 color;
void main() {
for (int i = 0; i < 3; ++i) { gl_Position = vec4(float(i)); color = vec4(1.0); EmitVertex(); }
EndPrimitive();
for (int i = 0; i < 3; ++i) { gl_Position = vec4(float(-i)); color = vec4(0.5); EmitVertex(); }
atomicAdd(n, 1u);
}
)";
const char* const kNoMemoryWrites = R"(#version 430 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() { gl_Position = gl_in[0].gl_Position; EmitVertex(); }
)";
} // namespace
TEST(DeferGeometryEmits, WritesAfterEmitAreMovedInFrontOfTheReplay) {
const Vector<Uint32> spirv = CompileGeometry(kWritesAfterEmit);
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> out;
bool deferred = false;
ASSERT_TRUE(ShaderCompiler::DeferGeometryEmitsForEssl(spirv, out, deferred, true));
ASSERT_TRUE(deferred);
String messages;
EXPECT_TRUE(Validates(out, messages)) << messages;
const String essl = Transpile(out);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(Count(essl, "EmitVertex()"), 1u) << essl;
const SizeT call = essl.find("mg_geometryBody();");
const SizeT emit = essl.find("EmitVertex();");
ASSERT_NE(call, String::npos) << essl;
EXPECT_LT(call, emit) << "the body (and its atomics) must run before the replayed emit\n" << essl;
EXPECT_EQ(essl.find("gl_ClipDistance"), String::npos) << "an unwritten builtin member became active\n" << essl;
EXPECT_EQ(essl.find("gl_PointSize"), String::npos) << essl;
}
TEST(DeferGeometryEmits, AStripCutAndAUserVaryingAreReplayed) {
const Vector<Uint32> spirv = CompileGeometry(kStripWithCut);
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> out;
bool deferred = false;
ASSERT_TRUE(ShaderCompiler::DeferGeometryEmitsForEssl(spirv, out, deferred, true));
ASSERT_TRUE(deferred);
String messages;
EXPECT_TRUE(Validates(out, messages)) << messages;
const String essl = Transpile(out);
EXPECT_EQ(Count(essl, "EmitVertex()"), 1u) << essl;
EXPECT_EQ(Count(essl, "EndPrimitive()"), 1u) << essl;
EXPECT_NE(essl.find("color = "), String::npos) << essl;
}
TEST(DeferGeometryEmits, AStageThatWritesNoMemoryIsLeftAlone) {
const Vector<Uint32> spirv = CompileGeometry(kNoMemoryWrites);
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> out;
bool deferred = true;
ASSERT_TRUE(ShaderCompiler::DeferGeometryEmitsForEssl(spirv, out, deferred, true));
EXPECT_FALSE(deferred);
}
@@ -982,6 +982,11 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
v.viewportIndexLoweringArmed = true;
variants.emplace_back("viewportIndexLoweringArmed", BuildEsslTranslationKey(v));
}
{ // arms DeferGeometryEmitsForEssl
EsslTranslationKeyInputs v = base;
v.deferGeometryEmitsArmed = true;
variants.emplace_back("deferGeometryEmitsArmed", BuildEsslTranslationKey(v));
}
{ // arms EmulateNoPerspectiveForEssl
EsslTranslationKeyInputs v = base;
v.supportsNoperspectiveInterpolation = true;
@@ -2061,6 +2061,23 @@ namespace MobileGL::MG_Util::BackendLoader {
: "false (DirectGLES will emit tessellation/geometry programs' interface "
"blocks without a location qualifier)");
switch (MG_Config::Features.EsprytDeferGeometryEmits) {
case MG_Config::QuirkOverride::ForceOn:
caps.DropsGeometryWritesAfterEmit = true;
break;
case MG_Config::QuirkOverride::ForceOff:
caps.DropsGeometryWritesAfterEmit = false;
break;
case MG_Config::QuirkOverride::Auto:
default:
caps.DropsGeometryWritesAfterEmit = SelfTest::GeometryStageSsboWriteAfterEmitDropped(glesFuncs);
break;
}
MGLOG_I(" Geometry-stage storage writes after EmitVertex are dropped: %s",
caps.DropsGeometryWritesAfterEmit
? "true (DirectGLES defers a writing geometry stage's emits behind its writes)"
: "false");
caps.IsAngleRenderer = caps.GLESRendererString.find("ANGLE") != String::npos;
caps.IsAngleLlvmpipeRenderer =
caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos;
@@ -1319,6 +1319,11 @@ namespace MobileGL {
// behaviour every driver had before the probe existed - a capability like this
// must never be assumed broken on a driver nobody measured.
Bool SupportsLocatedInterStageIoBlocks = true;
// True when a geometry shader's storage writes made AFTER EmitVertex() are dropped
// (SelfTest::GeometryStageSsboWriteAfterEmitDropped - measured on Adreno). DirectGLES
// then defers every emit of a geometry stage that writes memory behind those writes
// (DeferGeometryEmitsPass). Defaults FALSE: never assumed broken on an unmeasured driver.
Bool DropsGeometryWritesAfterEmit = false;
Int UniformBufferOffsetAlignment = 256;
// Its storage-buffer counterpart, queried separately because it is a separate limit:
// Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for
+15 -7
View File
@@ -2275,15 +2275,23 @@ namespace MobileGL::MG_Util::SelfTest {
Optional<DriverBugFinding> ProbeGeometryWriteAfterEmitBug(const GLESFunctionsTable& gl) {
if (!GeometryStageSsboWriteAfterEmitDropped(gl)) return std::nullopt;
return DriverBugFinding{
"Geometry-stage storage writes after EmitVertex",
DriverBugVerdict::Unfixable,
String detail =
"the driver silently discards shader storage buffer writes a geometry shader "
"issues after its last EmitVertex()/EndPrimitive(); the identical write issued "
"BEFORE the emit lands, for both point and triangle geometry shaders. "
"GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS is therefore NOT withdrawn - doing so "
"would break the shaders that write before emitting, which work correctly. "
"A shader that must write after emitting has no substitute on this driver"};
"BEFORE the emit lands, for both point and triangle geometry shaders. ";
// The repair follows the knob, so the row describes what this process does.
if (MG_Config::Features.EsprytDeferGeometryEmits == MG_Config::QuirkOverride::ForceOff) {
detail += "THE REPAIR IS DISABLED in this process (MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS), so a "
"geometry shader that writes after emitting loses those writes";
return DriverBugFinding{"Geometry-stage storage writes after EmitVertex",
DriverBugVerdict::Unfixable, Move(detail)};
}
detail += "DirectGLES defers every EmitVertex()/EndPrimitive() of a geometry stage that writes "
"memory: the outputs are snapshotted at each emit and the vertices replayed after the "
"shader body returns (DeferGeometryEmitsPass), so every write lands before any emit; "
"override with MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS";
return DriverBugFinding{"Geometry-stage storage writes after EmitVertex", DriverBugVerdict::Fixed,
Move(detail)};
}
Optional<DriverBugFinding> ProbeR32FMultisampleSwizzleBug(const GLESFunctionsTable& gl) {
@@ -50,6 +50,7 @@
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripIoBlockLocationsPass.h"
#include "SpirvPasses/DeferGeometryEmitsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
@@ -1454,6 +1455,19 @@ namespace MobileGL {
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::DeferGeometryEmitsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary, bool& outDeferred,
const bool enableSpirvValidation) {
using namespace spvtools;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
Bool deferred = false;
optimizer.RegisterPass(DeferGeometryEmitsPass::CreateDeferGeometryEmitsPass(&deferred));
const bool ok = RunOptimizerChecked("DeferGeometryEmitsForEssl", optimizer, inputBinary,
outputBinary, true, enableSpirvValidation);
outDeferred = ok && deferred;
return ok;
}
bool ShaderCompiler::DeclaresViewportIndexBuiltin(const Vector<Uint32>& binary) {
return LowerViewportIndexPass::DeclaresViewportIndexBuiltin(binary);
}
@@ -67,6 +67,12 @@ namespace MobileGL {
static bool LowerViewportIndexForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Moves a geometry stage's emits behind its memory writes (DeferGeometryEmitsPass), for
// an ES driver that drops storage writes made after EmitVertex(). `outDeferred` says
// whether the module was rewritten; a module the pass declines comes back unchanged.
static bool DeferGeometryEmitsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary, bool& outDeferred,
bool enableSpirvValidation = false);
// Whether the module declares an output decorated BuiltIn ViewportIndex, i.e.
// whether the pass above has anything to do. The gate that keeps every other
// stage off an optimizer round trip it does not need.
@@ -0,0 +1,510 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DeferGeometryEmitsPass.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 "DeferGeometryEmitsPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.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 <algorithm>
#include <memory>
#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>;
// Scalars of snapshot per invocation above which the pass declines. A geometry
// stage's own output budget (GL_MAX_GEOMETRY_TOTAL_OUTPUT_COMPONENTS) is 1024 on
// every ES 3.2 driver measured, so a legal shader stays well inside this; the cap
// only guards against a pathological one turning into a huge private array.
constexpr uint32_t kMaxSnapshotScalars = 8192;
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);
}
// The value of an OpConstant integer, or nullopt for anything else (a spec
// constant included: a length that can change after the pass cannot size a
// snapshot).
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);
}
// Scalar components a value of this type holds, or nullopt when it holds anything
// the snapshot cannot copy by value (a pointer, an opaque handle, a runtime array).
Optional<uint32_t> ScalarCount(IRContext* context, uint32_t typeId) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
if (type == nullptr) return std::nullopt;
switch (type->opcode()) {
case spv::Op::OpTypeInt:
case spv::Op::OpTypeFloat:
case spv::Op::OpTypeBool:
return 1u;
case spv::Op::OpTypeVector:
case spv::Op::OpTypeMatrix: {
const auto inner = ScalarCount(context, type->GetSingleWordInOperand(0));
if (!inner) return std::nullopt;
return *inner * type->GetSingleWordInOperand(1);
}
case spv::Op::OpTypeArray: {
const auto inner = ScalarCount(context, type->GetSingleWordInOperand(0));
const auto length = ConstantValue(context, type->GetSingleWordInOperand(1));
if (!inner || !length) return std::nullopt;
return *inner * *length;
}
case spv::Op::OpTypeStruct: {
uint32_t total = 0;
for (uint32_t i = 0; i < type->NumInOperands(); ++i) {
const auto inner = ScalarCount(context, type->GetSingleWordInOperand(i));
if (!inner) return std::nullopt;
total += *inner;
}
return total;
}
default:
return std::nullopt;
}
}
spv::StorageClass PointerStorageClass(IRContext* context, uint32_t pointerId) {
const Instruction* pointer = context->get_def_use_mgr()->GetDef(pointerId);
if (pointer == nullptr || pointer->type_id() == 0) return spv::StorageClass::Max;
const Instruction* type = context->get_def_use_mgr()->GetDef(pointer->type_id());
if (type == nullptr || type->opcode() != spv::Op::OpTypePointer) return spv::StorageClass::Max;
return static_cast<spv::StorageClass>(type->GetSingleWordInOperand(0));
}
// Every way a shader writes memory another invocation - or the application - can
// see: a store into a buffer or image block, an image store, any atomic.
Bool IsMemoryWrite(IRContext* context, const Instruction& inst) {
switch (inst.opcode()) {
case spv::Op::OpImageWrite:
case spv::Op::OpAtomicStore:
case spv::Op::OpAtomicExchange:
case spv::Op::OpAtomicCompareExchange:
case spv::Op::OpAtomicCompareExchangeWeak:
case spv::Op::OpAtomicIIncrement:
case spv::Op::OpAtomicIDecrement:
case spv::Op::OpAtomicIAdd:
case spv::Op::OpAtomicISub:
case spv::Op::OpAtomicSMin:
case spv::Op::OpAtomicUMin:
case spv::Op::OpAtomicSMax:
case spv::Op::OpAtomicUMax:
case spv::Op::OpAtomicAnd:
case spv::Op::OpAtomicOr:
case spv::Op::OpAtomicXor:
case spv::Op::OpAtomicFlagTestAndSet:
case spv::Op::OpAtomicFlagClear:
case spv::Op::OpAtomicFAddEXT:
case spv::Op::OpAtomicFMinEXT:
case spv::Op::OpAtomicFMaxEXT:
return true;
case spv::Op::OpStore: {
const spv::StorageClass storage = PointerStorageClass(context, inst.GetSingleWordInOperand(0));
return storage == spv::StorageClass::StorageBuffer || storage == spv::StorageClass::Uniform ||
storage == spv::StorageClass::Image ||
storage == spv::StorageClass::PhysicalStorageBuffer;
}
default:
return false;
}
}
// Whether anything stores through this pointer, directly or through an access
// chain into it. A pointer handed to a function call counts as stored: the callee
// is not followed, and an output passed by pointer is one the shader means to set.
Bool IsStoredThrough(IRContext* context, Instruction* pointer) {
Bool stored = false;
context->get_def_use_mgr()->WhileEachUser(pointer, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpStore:
if (user->GetSingleWordInOperand(0) == pointer->result_id()) stored = true;
break;
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
if (user->GetSingleWordInOperand(0) == pointer->result_id()) stored = true;
break;
case spv::Op::OpFunctionCall:
stored = true;
break;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
stored = IsStoredThrough(context, user);
break;
default:
break;
}
return !stored;
});
return stored;
}
// One output value the snapshot carries: a whole output variable, or one member
// of an output block.
struct Slot {
uint32_t variableId = 0;
Int64 member = -1;
uint32_t valueTypeId = 0;
// Filled once the globals exist.
uint32_t memberIndexId = 0;
uint32_t outputPointerTypeId = 0;
uint32_t snapshotVariableId = 0;
uint32_t snapshotElementPointerTypeId = 0;
};
void ReplaceName(IRContext* context, uint32_t id, const char* name) {
for (auto& debugInst : context->debugs2()) {
if (debugInst.opcode() == spv::Op::OpName && debugInst.GetSingleWordInOperand(0) == id) {
debugInst.SetInOperand(
1, spvtools::utils::MakeVector<spvtools::opt::Operand::OperandData>(name));
return;
}
}
context->AddDebug2Inst(Make(context, spv::Op::OpName, 0, 0,
{Id(id), {SPV_OPERAND_TYPE_LITERAL_STRING,
spvtools::utils::MakeVector(name)}}));
}
} // namespace
spvtools::opt::Pass::Status DeferGeometryEmitsPass::Process() {
IRContext* irContext = context();
if (m_outDeferred != nullptr) *m_outDeferred = false;
// One geometry entry point, sized by max_vertices.
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 bodyFunctionId = entryPoint->GetSingleWordInOperand(1);
uint32_t maxVertices = 0;
for (const Instruction& mode : irContext->module()->execution_modes()) {
if (mode.opcode() == spv::Op::OpExecutionMode &&
mode.GetSingleWordInOperand(0) == bodyFunctionId &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::OutputVertices) {
maxVertices = mode.GetSingleWordInOperand(2);
}
}
if (maxVertices == 0) return Status::SuccessWithoutChange;
std::vector<Instruction*> emits;
std::vector<Instruction*> cuts;
Bool writesMemory = false;
for (Function& function : *irContext->module()) {
Bool streams = false;
function.ForEachInst([&](Instruction* inst) {
switch (inst->opcode()) {
case spv::Op::OpEmitVertex: emits.push_back(inst); break;
case spv::Op::OpEndPrimitive: cuts.push_back(inst); break;
case spv::Op::OpEmitStreamVertex:
case spv::Op::OpEndStreamPrimitive: streams = true; break;
default:
if (!writesMemory && IsMemoryWrite(irContext, *inst)) writesMemory = true;
break;
}
});
if (streams) return Status::SuccessWithoutChange;
}
if (emits.empty() || !writesMemory) return Status::SuccessWithoutChange;
// What the snapshot carries.
auto* defUse = irContext->get_def_use_mgr();
auto* decorations = irContext->get_decoration_mgr();
std::vector<Slot> slots;
uint64_t snapshotScalars = 0;
for (Instruction& global : irContext->types_values()) {
if (global.opcode() != spv::Op::OpVariable ||
static_cast<spv::StorageClass>(global.GetSingleWordInOperand(0)) != spv::StorageClass::Output) {
continue;
}
const Instruction* pointerType = defUse->GetDef(global.type_id());
const uint32_t valueTypeId = pointerType->GetSingleWordInOperand(1);
const Instruction* valueType = defUse->GetDef(valueTypeId);
const Bool block = valueType->opcode() == spv::Op::OpTypeStruct &&
decorations->HasDecoration(valueTypeId, uint32_t(spv::Decoration::Block));
if (!block) {
if (!IsStoredThrough(irContext, &global)) continue;
const auto scalars = ScalarCount(irContext, valueTypeId);
if (!scalars) return Status::SuccessWithoutChange;
snapshotScalars += uint64_t(*scalars) * maxVertices;
slots.push_back({global.result_id(), -1, valueTypeId});
continue;
}
// A block: per written member, so an unwritten builtin member stays inactive.
std::vector<Bool> written(valueType->NumInOperands(), false);
Bool dynamicMember = false;
defUse->ForEachUser(&global, [&](Instruction* user) {
if (user->opcode() == spv::Op::OpStore && user->GetSingleWordInOperand(0) == global.result_id()) {
std::fill(written.begin(), written.end(), true);
return;
}
if (user->opcode() == spv::Op::OpFunctionCall) {
std::fill(written.begin(), written.end(), true);
return;
}
if (user->opcode() != spv::Op::OpAccessChain && user->opcode() != spv::Op::OpInBoundsAccessChain)
return;
if (user->NumInOperands() < 2) return;
const auto member = ConstantValue(irContext, user->GetSingleWordInOperand(1));
if (!member || *member >= written.size()) {
dynamicMember = true;
return;
}
if (IsStoredThrough(irContext, user)) written[*member] = true;
});
if (dynamicMember) return Status::SuccessWithoutChange;
for (uint32_t member = 0; member < written.size(); ++member) {
if (!written[member]) continue;
const uint32_t memberTypeId = valueType->GetSingleWordInOperand(member);
const auto scalars = ScalarCount(irContext, memberTypeId);
if (!scalars) return Status::SuccessWithoutChange;
snapshotScalars += uint64_t(*scalars) * maxVertices;
slots.push_back({global.result_id(), Int64(member), memberTypeId});
}
}
if (snapshotScalars > kMaxSnapshotScalars) return Status::SuccessWithoutChange;
// ---- Types, constants and Private globals, all before any code moves ----------
auto* typeMgr = irContext->get_type_mgr();
auto* constMgr = irContext->get_constant_mgr();
const uint32_t uintTypeId = typeMgr->GetUIntTypeId();
const uint32_t boolTypeId = typeMgr->GetBoolTypeId();
const uint32_t voidTypeId = typeMgr->GetVoidTypeId();
const uint32_t zeroId = constMgr->GetUIntConstId(0);
const uint32_t oneId = constMgr->GetUIntConstId(1);
const uint32_t lengthId = constMgr->GetUIntConstId(maxVertices);
const uint32_t lastId = constMgr->GetUIntConstId(maxVertices - 1);
const uint32_t cutLengthId = constMgr->GetUIntConstId(maxVertices + 1);
const uint32_t privateUintPointerId = typeMgr->FindPointerToType(uintTypeId, spv::StorageClass::Private);
const uint32_t functionUintPointerId = typeMgr->FindPointerToType(uintTypeId, spv::StorageClass::Function);
for (Slot& slot : slots) {
slot.snapshotElementPointerTypeId =
typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Private);
if (slot.member >= 0) {
slot.memberIndexId = constMgr->GetUIntConstId(uint32_t(slot.member));
slot.outputPointerTypeId = typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Output);
}
}
const uint32_t bodyFunctionTypeId = irContext->GetFunction(bodyFunctionId)->DefInst().GetSingleWordInOperand(1);
// Hand-built from here on; analyses are rebuilt once at the end.
auto newPrivateArray = [&](uint32_t elementTypeId, uint32_t lengthConstantId) {
const uint32_t arrayTypeId = irContext->TakeNextId();
irContext->AddType(Make(irContext, spv::Op::OpTypeArray, 0, arrayTypeId,
{Id(elementTypeId), Id(lengthConstantId)}));
const uint32_t pointerTypeId = irContext->TakeNextId();
irContext->AddType(Make(irContext, spv::Op::OpTypePointer, 0, pointerTypeId,
{{SPV_OPERAND_TYPE_STORAGE_CLASS, {uint32_t(spv::StorageClass::Private)}},
Id(arrayTypeId)}));
const uint32_t variableId = irContext->TakeNextId();
irContext->AddGlobalValue(Make(irContext, spv::Op::OpVariable, pointerTypeId, variableId,
{{SPV_OPERAND_TYPE_STORAGE_CLASS,
{uint32_t(spv::StorageClass::Private)}}}));
return variableId;
};
for (Slot& slot : slots) slot.snapshotVariableId = newPrivateArray(slot.valueTypeId, lengthId);
// One slot past max_vertices: where an EndPrimitive with no vertex before it lands.
const uint32_t cutVariableId = newPrivateArray(uintTypeId, cutLengthId);
const uint32_t countVariableId = irContext->TakeNextId();
irContext->AddGlobalValue(Make(irContext, spv::Op::OpVariable, privateUintPointerId, countVariableId,
{{SPV_OPERAND_TYPE_STORAGE_CLASS,
{uint32_t(spv::StorageClass::Private)}}}));
ReplaceName(irContext, countVariableId, "mg_deferredVertexCount");
ReplaceName(irContext, cutVariableId, "mg_deferredStripEnd");
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
// Emits a value-producing instruction before |at| and returns its id.
auto before = [&](Instruction* at, spv::Op op, uint32_t typeId, OperandList operands) {
const uint32_t id = typeId != 0 ? irContext->TakeNextId() : 0;
at->InsertBefore(Make(irContext, op, typeId, id, std::move(operands)));
return id;
};
auto outputPointer = [&](Instruction* at, const Slot& slot) {
if (slot.member < 0) return slot.variableId;
return before(at, spv::Op::OpAccessChain, slot.outputPointerTypeId,
{Id(slot.variableId), Id(slot.memberIndexId)});
};
// ---- Each EmitVertex records; each EndPrimitive marks ---------------------------
for (Instruction* emit : emits) {
const uint32_t count = before(emit, spv::Op::OpLoad, uintTypeId, {Id(countVariableId)});
const uint32_t inRange = before(emit, spv::Op::OpULessThan, boolTypeId, {Id(count), Id(lengthId)});
const uint32_t index = before(emit, spv::Op::OpSelect, uintTypeId, {Id(inRange), Id(count), Id(lastId)});
for (const Slot& slot : slots) {
const uint32_t source = outputPointer(emit, slot);
const uint32_t value = before(emit, spv::Op::OpLoad, slot.valueTypeId, {Id(source)});
const uint32_t target = before(emit, spv::Op::OpAccessChain, slot.snapshotElementPointerTypeId,
{Id(slot.snapshotVariableId), Id(index)});
before(emit, spv::Op::OpStore, 0, {Id(target), Id(value)});
}
const uint32_t cut = before(emit, spv::Op::OpAccessChain, privateUintPointerId,
{Id(cutVariableId), Id(index)});
before(emit, spv::Op::OpStore, 0, {Id(cut), Id(zeroId)});
const uint32_t next = before(emit, spv::Op::OpIAdd, uintTypeId, {Id(count), Id(oneId)});
before(emit, spv::Op::OpStore, 0, {Id(countVariableId), Id(next)});
}
for (Instruction* end : cuts) {
const uint32_t count = before(end, spv::Op::OpLoad, uintTypeId, {Id(countVariableId)});
const uint32_t any = before(end, spv::Op::OpINotEqual, boolTypeId, {Id(count), Id(zeroId)});
const uint32_t previous = before(end, spv::Op::OpISub, uintTypeId, {Id(count), Id(oneId)});
const uint32_t inRange = before(end, spv::Op::OpULessThan, boolTypeId, {Id(previous), Id(lengthId)});
const uint32_t clamped =
before(end, spv::Op::OpSelect, uintTypeId, {Id(inRange), Id(previous), Id(lastId)});
const uint32_t index = before(end, spv::Op::OpSelect, uintTypeId, {Id(any), Id(clamped), Id(lengthId)});
const uint32_t cut = before(end, spv::Op::OpAccessChain, privateUintPointerId,
{Id(cutVariableId), Id(index)});
before(end, spv::Op::OpStore, 0, {Id(cut), Id(oneId)});
}
for (Instruction* inst : emits) irContext->KillInst(inst);
for (Instruction* inst : cuts) irContext->KillInst(inst);
// ---- The new entry point: run the body, then replay -----------------------------
const uint32_t entryId = irContext->TakeNextId();
auto function = spvtools::MakeUnique<Function>(
Make(irContext, spv::Op::OpFunction, voidTypeId, entryId,
{{SPV_OPERAND_TYPE_FUNCTION_CONTROL, {0}}, Id(bodyFunctionTypeId)}));
Function* entry = function.get();
const uint32_t startLabel = irContext->TakeNextId();
const uint32_t headerLabel = irContext->TakeNextId();
const uint32_t checkLabel = irContext->TakeNextId();
const uint32_t replayLabel = irContext->TakeNextId();
const uint32_t endStripLabel = irContext->TakeNextId();
const uint32_t afterStripLabel = irContext->TakeNextId();
const uint32_t continueLabel = irContext->TakeNextId();
const uint32_t mergeLabel = irContext->TakeNextId();
auto newBlock = [&](uint32_t label) {
auto block = spvtools::MakeUnique<BasicBlock>(Make(irContext, spv::Op::OpLabel, 0, label, {}));
block->SetParent(entry);
return block;
};
auto add = [&](BasicBlock& block, spv::Op op, uint32_t typeId, OperandList operands) {
const uint32_t id = typeId != 0 ? irContext->TakeNextId() : 0;
block.AddInstruction(Make(irContext, op, typeId, id, std::move(operands)));
return id;
};
auto start = newBlock(startLabel);
const uint32_t iterator = add(*start, spv::Op::OpVariable, functionUintPointerId,
{{SPV_OPERAND_TYPE_STORAGE_CLASS, {uint32_t(spv::StorageClass::Function)}}});
add(*start, spv::Op::OpStore, 0, {Id(countVariableId), Id(zeroId)});
add(*start, spv::Op::OpFunctionCall, voidTypeId, {Id(bodyFunctionId)});
const uint32_t recorded = add(*start, spv::Op::OpLoad, uintTypeId, {Id(countVariableId)});
const uint32_t fits = add(*start, spv::Op::OpULessThan, boolTypeId, {Id(recorded), Id(lengthId)});
const uint32_t limit = add(*start, spv::Op::OpSelect, uintTypeId, {Id(fits), Id(recorded), Id(lengthId)});
add(*start, spv::Op::OpStore, 0, {Id(iterator), Id(zeroId)});
add(*start, spv::Op::OpBranch, 0, {Id(headerLabel)});
auto header = newBlock(headerLabel);
add(*header, spv::Op::OpLoopMerge, 0,
{Id(mergeLabel), Id(continueLabel), {SPV_OPERAND_TYPE_LOOP_CONTROL, {0}}});
add(*header, spv::Op::OpBranch, 0, {Id(checkLabel)});
auto check = newBlock(checkLabel);
const uint32_t vertex = add(*check, spv::Op::OpLoad, uintTypeId, {Id(iterator)});
const uint32_t more = add(*check, spv::Op::OpULessThan, boolTypeId, {Id(vertex), Id(limit)});
add(*check, spv::Op::OpBranchConditional, 0, {Id(more), Id(replayLabel), Id(mergeLabel)});
auto replay = newBlock(replayLabel);
for (const Slot& slot : slots) {
const uint32_t source = add(*replay, spv::Op::OpAccessChain, slot.snapshotElementPointerTypeId,
{Id(slot.snapshotVariableId), Id(vertex)});
const uint32_t value = add(*replay, spv::Op::OpLoad, slot.valueTypeId, {Id(source)});
uint32_t target = slot.variableId;
if (slot.member >= 0) {
target = add(*replay, spv::Op::OpAccessChain, slot.outputPointerTypeId,
{Id(slot.variableId), Id(slot.memberIndexId)});
}
add(*replay, spv::Op::OpStore, 0, {Id(target), Id(value)});
}
add(*replay, spv::Op::OpEmitVertex, 0, {});
const uint32_t cutPointer =
add(*replay, spv::Op::OpAccessChain, privateUintPointerId, {Id(cutVariableId), Id(vertex)});
const uint32_t cutValue = add(*replay, spv::Op::OpLoad, uintTypeId, {Id(cutPointer)});
const uint32_t endsStrip = add(*replay, spv::Op::OpINotEqual, boolTypeId, {Id(cutValue), Id(zeroId)});
add(*replay, spv::Op::OpSelectionMerge, 0,
{Id(afterStripLabel), {SPV_OPERAND_TYPE_SELECTION_CONTROL, {0}}});
add(*replay, spv::Op::OpBranchConditional, 0, {Id(endsStrip), Id(endStripLabel), Id(afterStripLabel)});
auto endStrip = newBlock(endStripLabel);
add(*endStrip, spv::Op::OpEndPrimitive, 0, {});
add(*endStrip, spv::Op::OpBranch, 0, {Id(afterStripLabel)});
auto afterStrip = newBlock(afterStripLabel);
add(*afterStrip, spv::Op::OpBranch, 0, {Id(continueLabel)});
auto continueBlock = newBlock(continueLabel);
const uint32_t current = add(*continueBlock, spv::Op::OpLoad, uintTypeId, {Id(iterator)});
const uint32_t next = add(*continueBlock, spv::Op::OpIAdd, uintTypeId, {Id(current), Id(oneId)});
add(*continueBlock, spv::Op::OpStore, 0, {Id(iterator), Id(next)});
add(*continueBlock, spv::Op::OpBranch, 0, {Id(headerLabel)});
auto merge = newBlock(mergeLabel);
add(*merge, spv::Op::OpReturn, 0, {});
entry->AddBasicBlock(std::move(start));
entry->AddBasicBlock(std::move(header));
entry->AddBasicBlock(std::move(check));
entry->AddBasicBlock(std::move(replay));
entry->AddBasicBlock(std::move(endStrip));
entry->AddBasicBlock(std::move(afterStrip));
entry->AddBasicBlock(std::move(continueBlock));
entry->AddBasicBlock(std::move(merge));
entry->SetFunctionEnd(Make(irContext, spv::Op::OpFunctionEnd, 0, 0, {}));
irContext->AddFunction(std::move(function));
// The entry point and its execution modes now name the new function.
entryPoint->SetInOperand(1, {entryId});
for (Instruction& mode : irContext->module()->execution_modes()) {
if (mode.GetSingleWordInOperand(0) == bodyFunctionId) mode.SetInOperand(0, {entryId});
}
ReplaceName(irContext, bodyFunctionId, "mg_geometryBody");
ReplaceName(irContext, entryId, "main");
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
if (m_outDeferred != nullptr) *m_outDeferred = true;
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken DeferGeometryEmitsPass::CreateDeferGeometryEmitsPass(Bool* outDeferred) {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<DeferGeometryEmitsPass>(outDeferred));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,57 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DeferGeometryEmitsPass.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 {
// A geometry shader's memory writes, issued after its EmitVertex() calls, moved IN
// FRONT of every emit - for an ES driver that silently drops shader storage writes a
// geometry invocation makes after emitting (SelfTest::GeometryStageSsboWriteAfterEmitDropped,
// measured on Adreno; KHR-GL4x.shader_storage_buffer_object.basic-atomic-case1/3 is the
// CTS shape: EmitVertex() first, then a chain of atomics on a geometry-stage buffer).
//
// The emits are DEFERRED rather than the writes hoisted, because only the emits can be
// moved without changing what the shader computes: an emit reads nothing but the
// output variables' values at that moment, and those are snapshotted.
// * The original entry function becomes an ordinary function (mg_geometryBody), and a
// new entry point calls it and then replays the vertices - so a `return` anywhere
// in the original body, however deep, still ends up at the replay.
// * Each OpEmitVertex becomes a copy of every output the shader writes into a Private
// array sized by the OutputVertices execution mode (max_vertices), plus a bump of
// the vertex count; each OpEndPrimitive marks the last recorded vertex as the end
// of a strip. Both are straight-line code - no new control flow inside the body.
// * The replay loop restores each vertex's outputs, emits it, and ends the strip
// where the body did.
// Only outputs the shader STORES to are carried (a block member it never writes, such
// as gl_ClipDistance, would otherwise become an active builtin the ESSL stage cannot
// declare). Emits past max_vertices are undefined in GL; they overwrite the last slot.
//
// Declines (leaves the module untouched) when: the module is not a geometry stage, it
// has no emit or no memory write at all, it uses vertex streams, a block output is
// indexed dynamically at its top level, or the snapshot would exceed a per-invocation
// budget. Only for the DirectGLES transpile path on a driver the POST measured.
class DeferGeometryEmitsPass : public spvtools::opt::Pass {
public:
explicit DeferGeometryEmitsPass(Bool* outDeferred = nullptr) : m_outDeferred(outDeferred) {}
const char* name() const override { return "defer-geometry-emits"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateDeferGeometryEmitsPass(Bool* outDeferred = nullptr);
private:
Bool* m_outDeferred = nullptr;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -181,6 +181,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
AppendCommonKeyPrefix(builder, kEsslKeyTag);
builder.Value(static_cast<Uint32>(inputs.shaderType));
builder.Value(static_cast<Uint8>(inputs.viewportIndexLoweringArmed));
builder.Value(static_cast<Uint8>(inputs.deferGeometryEmitsArmed));
builder.Value(static_cast<Uint8>(inputs.supportsNoperspectiveInterpolation));
builder.Value(static_cast<Uint8>(inputs.supportsExtendedImageFormats));
builder.Value(inputs.maxColorTextureSamples);
@@ -643,6 +643,10 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// decides, and a key carrying only it would serve a module lowered under one setting to a
// link made under the other.
Bool viewportIndexLoweringArmed = false;
// Whether DeferGeometryEmitsForEssl runs on this (geometry) module: the driver POST
// measured dropped storage writes after EmitVertex, or MOBILEGL_ESPRYT_DEFER_GEOMETRY_EMITS
// forced it.
Bool deferGeometryEmitsArmed = false;
Bool supportsNoperspectiveInterpolation = false;
// GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage
// image whose format GLSL ES core cannot spell in the core format that carries it and