[Feat] (DirectGLES, Config, ShaderTranspiler): route gl_ViewportIndex on Espryt by replaying a draw per distinct viewport state

This commit is contained in:
2026-08-21 22:08:25 -04:00
parent ece9491d4b
commit 908172ba0f
8 changed files with 612 additions and 54 deletions
+247 -8
View File
@@ -47,6 +47,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
constexpr const char* INDIRECT_PARAMS_BLOCK_NAME = "mg_IndirectParams";
constexpr const char* ZERO_BASED_INSTANCE_ID_NAME = "mg_ZeroBasedInstanceID";
// See the block comment on ForEachViewportRoutingPass in Managers.h. Auto is ON, including on
// a driver that advertises GL_OES_viewport_array: that extension gives the shader a name, not
// the driver fifteen more rectangles to rasterize against, and nothing in MobileGL has ever
// programmed the indexed state it would need.
Bool ViewportArrayEmulationEnabled() {
return MG_Config::Features.ViewportArrayEmulation != MG_Config::QuirkOverride::ForceOff;
}
Bool g_anyProgramRoutesViewportIndex = false;
// ES has no atomic-counter buffers: glslang lowers every atomic_uint onto a synthesized
// storage block, so one GL counter BUFFER costs one of the driver's shader-storage binding
// points. Those slots are taken from the TOP of the range downwards - below the one
@@ -300,6 +310,126 @@ namespace MobileGL::MG_Backend::DirectGLES {
return source;
}
// ---- gl_ViewportIndex routing emulation, ESSL half ---------------------------------------
//
// LowerViewportIndexPass has already turned the BuiltIn ViewportIndex OUTPUT into a plain
// Private global, so SPIRV-Cross printed `int mg_ViewportIndex;` at file scope and the stage
// still stores the index the application asked for - it just goes nowhere. The two passes
// below give it somewhere to go WITHOUT naming a builtin the language does not have: the
// producing stage's global becomes an ordinary flat varying, and the fragment stage gets a
// gate that discards every fragment whose primitive routed to a viewport the current replay
// pass is not drawing. DirectGLES.cpp's ForEachViewportRoutingPass is the other half - it
// re-issues the draw once per distinct viewport state with the real
// glViewport/glScissor/glDepthRangef pushed for it and this uniform set to the set of
// indices that state serves.
//
// FLAT is semantics, not performance: GL takes a primitive's viewport index from its
// PROVOKING VERTEX, which is exactly what flat interpolation delivers, so a primitive whose
// vertices carry different indices routes the way the spec says with no extra machinery.
//
// NO layout(location = N) on either side, deliberately. The two stages are transpiled
// independently and neither can see the other's location assignment: the producing stage
// knows its own outputs, the fragment stage only the subset it consumes, and a number derived
// from either can disagree with the other. Leaving both unqualified hands the assignment to
// the driver's linker, which then matches them BY NAME - the ordinary GLSL rule, and the only
// one that needs no cross-stage channel. The cost is one varying slot, which a program
// already at GL_MAX_VARYING_VECTORS cannot spare.
constexpr const char* VIEWPORT_INDEX_VARYING_NAME = "mg_ViewportIndex";
constexpr const char* VIEWPORT_PASS_MASK_UNIFORM_NAME = "mg_ViewportPassMask";
constexpr const char* VIEWPORT_GATED_ENTRY_POINT_NAME = "mg_ViewportGatedMain";
constexpr const char* ESSL_ENTRY_POINT_SIGNATURE = "void main()";
static_assert(RenderStateParameters::MAX_VIEWPORTS == 16,
"the fragment gate below spells the index clamp as `& 15` and the pass mask as a "
"16-bit int; both follow MAX_VIEWPORTS and have to be respelled with it");
// Producing stage (vertex / tessellation evaluation / geometry - the three GL lets write the
// builtin). Returns whether the demoted global was found and promoted, which is also the
// answer to "does this program route viewports at all".
Bool PromoteViewportIndexGlobalToVarying(String& source) {
// The same shape PromoteDrawParameterGlobalsToUniforms matches, and for the same reason:
// SPIRV-Cross prints the demoted global with or without a precision qualifier depending
// on what the module carried. Only a declaration that starts its own line may be
// rewritten - `mg_ViewportIndex = gl_InvocationID;` in the body contains the name too and
// has to be left exactly as it is.
const String declared = String(VIEWPORT_INDEX_VARYING_NAME) + ";";
for (const char* declPrefix : {"highp int ", "mediump int ", "lowp int ", "int "}) {
const String declaration = String(declPrefix) + declared;
const SizeT pos = source.find(declaration);
if (pos == String::npos) {
continue;
}
// Column 0 of its own line is what separates the declaration from the tail of any
// other declaration or expression that ends in the same name.
if (pos != 0 && source[pos - 1] != '\n') {
continue;
}
source.replace(pos, declaration.size(),
String("flat out highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";");
return true;
}
return false;
}
// Fragment stage. Returns false when the stage has no entry point to gate onto, which the
// caller reports: the program still links and still renders, it just renders every index
// with the first replay pass's state - i.e. it degrades to the pre-emulation behaviour
// rather than to a black screen.
Bool InjectViewportIndexPassGate(String& source) {
// Built beside the input and swapped in only on success, so a stage this pass declines
// reaches the driver exactly as it arrived rather than half-rewritten.
// A fragment stage that READS gl_ViewportIndex has no ESSL spelling for it either -
// LowerViewportIndexPass deliberately demotes only OUTPUTS, because a demoted INPUT would
// answer from an undefined Private global. Now that the routing varying exists and
// carries the real per-primitive value, that read has somewhere honest to go.
String gated = ReplaceIdentifier(source, "gl_ViewportIndex", VIEWPORT_INDEX_VARYING_NAME);
const SizeT entryPos = gated.find(ESSL_ENTRY_POINT_SIGNATURE);
if (entryPos == String::npos) {
return false;
}
// Declarations go immediately before the entry point rather than after #version: that
// position is already past every #extension directive (which must precede any other
// token) and past everything the body can name, so it can invalidate neither.
//
// Renaming the entry point rather than splicing a prologue into its body keeps the
// application's code byte-identical, including an early `return`.
String preamble = String("flat in highp int ") + VIEWPORT_INDEX_VARYING_NAME + ";\n";
preamble += String("uniform highp int ") + VIEWPORT_PASS_MASK_UNIFORM_NAME + ";\n";
preamble += String("void ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "()";
gated.replace(entryPos, std::strlen(ESSL_ENTRY_POINT_SIGNATURE), preamble);
// `& 15` clamps the shift operand into range for MAX_VIEWPORTS = 16. GL leaves an index
// outside [0, MAX_VIEWPORTS) undefined, but an ESSL shift by >= 32 is undefined in a way
// that can take the whole draw with it, so the emulation picks a defined answer instead.
//
// The mask, not an equality test against a pass number: viewport indices whose whole
// state tuple is identical share ONE replay pass (see BeginViewportRoutingPasses), and
// the overwhelmingly common case - every index still holding what glViewport broadcast -
// is then a single pass with every bit set, i.e. a gate that discards nothing and a draw
// that is issued exactly once.
//
// PERFORMANCE NOTE: a fragment shader containing `discard` cannot take the early-Z fast
// path on a tiler, so a routed draw pays late-Z on top of its N replay passes. Accepted
// deliberately: this runs only for a program that writes gl_ViewportIndex, and that is
// why the gate is injected per program rather than into every fragment shader.
gated += "\n";
gated += String(ESSL_ENTRY_POINT_SIGNATURE) + "\n";
gated += "{\n";
gated += String(" if (((") + VIEWPORT_PASS_MASK_UNIFORM_NAME + " >> (" +
VIEWPORT_INDEX_VARYING_NAME + " & 15)) & 1) == 0)\n";
gated += " {\n";
gated += " discard;\n";
gated += " }\n";
gated += " else\n";
gated += " {\n";
gated += String(" ") + VIEWPORT_GATED_ENTRY_POINT_NAME + "();\n";
gated += " }\n";
gated += "}\n";
source = std::move(gated);
return true;
}
// The transpile pipeline invents image binding numbers: when the GL source declares
// an image uniform without layout(binding), glslang auto-assigns one (desktop GL
// allows that and lets the app pick the unit with glUniform1i, which ES forbids on
@@ -5493,7 +5623,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
// keep the two in step.
const Int advertisedMaxSamples =
std::max(g_GLESCapabilities.MaxSamples, kFrontendMaxSamples);
const Bool viewportLoweringArmed = !g_GLESCapabilities.SupportsViewportArray;
// Armed by the EMULATION as well as by the missing extension, and the emulation is on
// by default (MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION). Having the extension is not a
// reason to keep the builtin: it only ever gave the SHADER a compilable name, while
// the driver's INDEXED viewport state was never programmed by anything in MobileGL
// (SyncRenderState pushes index 0 and stops), so an extension-capable driver
// rasterized every index as index 0 exactly like a driver without it. Lowering here
// is what lets the ESSL passes downstream turn the builtin into the flat varying the
// replay gates on.
//
// Restricted to the three stages GL lets WRITE the builtin (4.1 core gives it to the
// geometry stage, ARB_shader_viewport_layer_array adds vertex and tessellation
// evaluation). A fragment stage's gl_ViewportIndex is an INPUT, which the pass
// declines anyway, and a compute stage has none - so arming those two only ever
// bought them the shared probe's BuildModule for nothing.
const Bool stageCanWriteViewportIndex = glShaderType == GL_VERTEX_SHADER ||
glShaderType == GL_TESS_EVALUATION_SHADER ||
glShaderType == GL_GEOMETRY_SHADER;
const Bool viewportLoweringArmed =
stageCanWriteViewportIndex &&
(ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray);
const Bool sampleClampArmed =
g_GLESCapabilities.MaxColorTextureSamples < advertisedMaxSamples ||
g_GLESCapabilities.MaxIntegerSamples < advertisedMaxSamples ||
@@ -5517,11 +5666,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
*effectiveSpirv, loweredViewportSpirv, enableSpirvValidation) &&
!loweredViewportSpirv.empty()) {
effectiveSpirv = &loweredViewportSpirv;
MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which this ES driver has "
"no GL_OES_viewport_array for. The builtin was demoted to a plain "
"global; every invocation renders into viewport 0.",
MGLOG_D("Program %u stage %s writes gl_ViewportIndex, which ESSL has no core "
"spelling for. The builtin was demoted to a plain global; %s.",
m_backendProgramId,
MG_Util::ConvertGLEnumToString(glShaderType).c_str());
MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
ViewportArrayEmulationEnabled()
? "the ESSL passes below promote it to a routing varying"
: "every invocation renders into viewport 0");
}
// GL 4.6 core table 23.53 requires GL_MAX_SAMPLES >= 4, so every multisample
@@ -6233,7 +6384,40 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage;
// The stage order the loop below walks, with every FRAGMENT stage moved to the end.
// The viewport-routing gate is the reason: whether a fragment stage needs one is a
// question about the OTHER stages ("does any of them still write gl_ViewportIndex?"),
// and the honest, free answer to it is the promotion the producing stage's own text
// pass just performed. Answering it any other way costs a BuildModule per
// pre-rasterization stage of every program - the parse the shared SpirvGateFeatures
// probe exists to avoid. Nothing else in the loop is order-sensitive: the two
// passthrough-tessellation sources it captures are a vertex and an evaluation stage,
// and the three sets it accumulates are unions.
Vector<SizeT> stageOrder;
stageOrder.reserve(linkedStages.size());
for (SizeT index = 0; index < linkedStages.size(); ++index) {
if (linkedStages[index] != ShaderStage::Fragment) stageOrder.push_back(index);
}
for (SizeT index = 0; index < linkedStages.size(); ++index) {
if (linkedStages[index] == ShaderStage::Fragment) stageOrder.push_back(index);
}
// Set by whichever pre-rasterization stage's demoted mg_ViewportIndex global the text
// pass turned into a varying; read by the fragment stage to decide whether to inject
// the gate that consumes it.
Bool programRoutesViewportIndex = false;
// No fragment stage, no gate - and without a gate the promotion below would only add
// an output nothing can read. That is not merely useless: in a separable program
// pipeline the fragment stage lives in a DIFFERENT program, which never saw this
// build and cannot be given a gate, so promoting there would hang an unmatched
// varying off a program to buy nothing. Both cases keep the pre-emulation behaviour,
// which is what a program with no fragment stage had anyway.
const Bool programHasFragmentStage =
std::find(linkedStages.begin(), linkedStages.end(), ShaderStage::Fragment) !=
linkedStages.end();
const Bool viewportEmulationForThisProgram =
ViewportArrayEmulationEnabled() && programHasFragmentStage;
for (const SizeT index : stageOrder) {
GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(linkedStages[index]);
GLuint backendShaderId = g_GLESFuncs.glCreateShader(glShaderType);
@@ -6272,7 +6456,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::EsslTranslationKeyInputs esslKeyInputs;
esslKeyInputs.spirv = &spirvCode;
esslKeyInputs.shaderType = glShaderType;
esslKeyInputs.supportsViewportArray = g_GLESCapabilities.SupportsViewportArray;
// The EFFECTIVE arming, computed the same way TranspileSpirvToEssl computes it.
// Duplicated rather than shared because the two live on opposite sides of the
// memo boundary - and a key that disagrees with the pass it is keying is the one
// failure mode of this cache that renders wrong pixels instead of being slow.
esslKeyInputs.viewportIndexLoweringArmed =
(glShaderType == GL_VERTEX_SHADER || glShaderType == GL_TESS_EVALUATION_SHADER ||
glShaderType == GL_GEOMETRY_SHADER) &&
(ViewportArrayEmulationEnabled() || !g_GLESCapabilities.SupportsViewportArray);
esslKeyInputs.supportsNoperspectiveInterpolation =
g_GLESCapabilities.SupportsNoperspectiveInterpolation;
esslKeyInputs.supportsExtendedImageFormats =
@@ -6427,8 +6618,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
// name; a driver without the extension took the LowerViewportIndexPass fallback
// above and its source no longer names the builtin at all, so the two are mutually
// exclusive by construction. Read `source` BEFORE it is moved from.
const Bool needsViewportArrayExtension = g_GLESCapabilities.SupportsViewportArray &&
source.find("gl_ViewportIndex") != String::npos;
// The routing emulation is the third way this can be reached and the only one
// that needs no directive: it renames the fragment stage's read onto the varying
// the producing stage now writes, a few passes below.
const Bool needsViewportArrayExtension =
g_GLESCapabilities.SupportsViewportArray &&
!(ViewportArrayEmulationEnabled() && programRoutesViewportIndex) &&
source.find("gl_ViewportIndex") != String::npos;
source = RequestViewportArrayExtension(std::move(source), needsViewportArrayExtension);
source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject);
@@ -6489,6 +6685,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
source = EmulateTextureLodBias(source, ShouldAvoidExplicitLodBiasOnAngleLlvmpipe());
source = EmulateBaseInstanceInVertexShader(std::move(source), glShaderType);
source = PromoteDrawParameterGlobalsToUniforms(std::move(source), glShaderType);
// The two halves of the gl_ViewportIndex routing emulation, next to the draw-
// parameter promotion because they are the same shape: a builtin ESSL cannot
// spell, demoted to a plain global by a SPIR-V pass, given a real interface here.
// BEFORE ForceSupporterOutput, so the `precision highp` statements it hoists to
// the top land above the declarations these inject; AFTER
// ForceFlatIntegerVaryings, which matches only declarations carrying a
// layout(...) qualifier and so cannot touch either of them.
if (viewportEmulationForThisProgram) {
if (glShaderType == GL_FRAGMENT_SHADER) {
if (programRoutesViewportIndex && !InjectViewportIndexPassGate(source)) {
// MGLOG_E, unlatched, like the transpile- and compile-failure
// diagnostics around it: the program still links and still draws, so
// nothing else in the process will ever say that its viewport routing
// silently collapsed back to one rectangle.
MGLOG_E("Program %u routes gl_ViewportIndex but its fragment stage has no "
"entry point to gate, so the routing cannot be emulated: every "
"index will rasterize against viewport 0. State program ID: %u.",
m_backendProgramId, stateProgramObject->GetExternalIndex());
}
} else if (PromoteViewportIndexGlobalToVarying(source)) {
programRoutesViewportIndex = true;
}
}
source = ForceSupporterOutput(source);
source = ClampNormFallbackOutputs(std::move(source), glShaderType,
m_snormFallbackClampOutputMask,
@@ -6691,6 +6910,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
BASE_VERTEX_UNIFORM_NAME);
m_baseInstanceWordIndexUniformLocation =
g_GLESFuncs.glGetUniformLocation(m_backendProgramId, BASE_INSTANCE_WORD_INDEX_UNIFORM_NAME);
// Asked of the DRIVER rather than remembered from the injection, deliberately: the
// gate is only real if the uniform survived compilation and linking, and this is the
// one question whose answer covers both. A gate the driver optimized away would
// otherwise leave the draw path replaying passes whose mask reaches nothing, which
// renders every index's primitives in every pass.
m_viewportPassMaskUniformLocation =
g_GLESFuncs.glGetUniformLocation(m_backendProgramId, VIEWPORT_PASS_MASK_UNIFORM_NAME);
if (m_viewportPassMaskUniformLocation >= 0) {
// Sticky, and never cleared on a relink: it only ever short-circuits a per-draw
// check, so being late to go false costs a pointer compare and being late to go
// true would cost correctness.
g_anyProgramRoutesViewportIndex = true;
}
// The mg_IndirectParams block binding is baked into the ESSL (ES cannot rebind
// SSBO blocks after compile); record it so draws bind the indirect buffer there.
m_indirectParamsBinding = -1;
@@ -6893,6 +7125,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
g_GLESFuncs.glUniform1i(m_drawIdUniformLocation, static_cast<GLint>(drawId));
}
void BackendProgramObjectImpl::SetViewportPassMask(Uint32 indexMask) const {
if (m_viewportPassMaskUniformLocation < 0) {
return;
}
g_GLESFuncs.glUniform1i(m_viewportPassMaskUniformLocation, static_cast<GLint>(indexMask));
}
} // namespace PrgramImpl
namespace SamplerImpl {