[Fix] (ShaderTranspiler, Link, DirectGLES, DirectVulkan): demote tessellation/geometry gl_PointSize to an ordinary varying where the device cannot host the built-in - the value survives for gl_in reads and by-name capture, both backends' declines stay for shapes the pass refuses, and the verdict rides the L1 key

This commit is contained in:
2026-08-28 06:21:30 -04:00
parent 92dc41ebf9
commit d7f66722d1
21 changed files with 1320 additions and 6 deletions
+1
View File
@@ -307,6 +307,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
+13
View File
@@ -152,6 +152,19 @@ 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_POINT_SIZE_DEMOTION: demote gl_PointSize out of tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) instead of declining such programs
// on a device that advertises neither EXT/OES_tessellation_point_size /
// geometry_point_size (DirectGLES) nor shaderTessellationAndGeometryPointSize
// (DirectVulkan). Auto arms it exactly where the detection says the capability is
// absent, which is the right setting everywhere. ForceOn exists so the demotion can
// be exercised on a healthy driver - llvmpipe and lavapipe host the built-in
// natively and would otherwise never run this code, which is what the pinned
// integration lane uses - and ForceOff restores the plain declines (escape hatch /
// negative control). Cross-backend by design: the demotion runs in the shared
// phase-B chain, so one switch covers both. See DemotePointSizePass.
QuirkOverride PointSizeDemotion = QuirkOverride::Auto;
// MOBILEGL_COHERENT_AS_FLUSH: app-compat for engines (e.g. Flywheel) that write
// GPU-read data through persistent GL_MAP_FLUSH_EXPLICIT_BIT maps they never
// flush. Persistent FLUSH_EXPLICIT map requests are rewritten to coherent
+1
View File
@@ -181,6 +181,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.PointSizeDemotion = QueryEnvQuirkOverride("MOBILEGL_POINT_SIZE_DEMOTION");
features.CoherentAsFlush = QueryEnvFlag("MOBILEGL_COHERENT_AS_FLUSH");
features.TraceSkipAutodestroy = QueryEnvFlag("MOBILEGL_TRACE_SKIP_AUTODESTROY");
features.EsprytDisableUboRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UBO_RING");
+18
View File
@@ -495,6 +495,24 @@ namespace MobileGL {
// halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat)
// still see one consistent world.
Bool SupportsFloat64VertexAttributes = false;
// Whether a TESSELLATION stage of this backend may access gl_PointSize - i.e.
// whether a module declaring OpCapability TessellationPointSize can reach the
// driver at all. DirectVulkan sets both this and the geometry twin from the one
// shaderTessellationAndGeometryPointSize feature; DirectGLES sets them
// independently from the EXT/OES_tessellation_point_size /
// geometry_point_size extension pairs (PointSizeTier), which really do come
// separately. When absent, ProgramSpirvTask demotes the built-in to an ordinary
// varying program-wide (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram); MOBILEGL_POINT_SIZE_DEMOTION
// overrides the detection in either direction at backend init.
//
// Defaults TRUE, deliberately against the house "assume absent" rule: false
// ARMS a rewrite, so the conservative no-backend answer (standalone compiles,
// unit tests) is the one that leaves modules untouched. A backend that never
// sets it gets standard modules and, at worst, the old honest declines.
Bool SupportsTessellationPointSize = true;
// The geometry-stage twin (OpCapability GeometryPointSize).
Bool SupportsGeometryPointSize = true;
SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024;
Uint32 SubgroupSize = 0;
Uint32 SubgroupSupportedStages = 0;
@@ -1479,6 +1479,36 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Follows the line above, and must: OpenGL ES has no double-precision vertex format and no
// fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to land here.
m_dynamicParameters.SupportsFloat64VertexAttributes = false;
// Whether a tessellation / geometry stage's ESSL may name gl_PointSize at all: the two
// extension pairs the loader probed, independently, because they really do come
// separately. False arms the shared phase-B demotion
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), whose ESSL then
// never names the built-in in those stages and needs no extension.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends both are absent so the demotion can be
// exercised on a healthy driver (the pinned integration lane); =0 restores the
// detected answer's declines.
m_dynamicParameters.SupportsTessellationPointSize =
m_GLESCapabilities.TessellationPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
m_dynamicParameters.SupportsGeometryPointSize =
m_GLESCapabilities.GeometryPointSizeSupport !=
MG_External::GLESCapabilities::PointSizeTier::None;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
m_dynamicParameters.SupportsTessellationPointSize = false;
m_dynamicParameters.SupportsGeometryPointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectGLES: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the driver's extensions");
m_dynamicParameters.SupportsTessellationPointSize = true;
m_dynamicParameters.SupportsGeometryPointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers;
m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments;
m_dynamicParameters.MaxClipDistances = m_GLESCapabilities.MaxClipDistances;
@@ -7342,6 +7342,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
m_backendProgramUsable = false;
return;
}
if (stateProgramObject->PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that
// pins MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because
// every rendering assertion stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectGLES is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this driver cannot host the built-in "
"in those stages.");
}
MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId);
for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) {
if (!ref.shader) continue;
@@ -8012,6 +8021,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// for; it has the variable that replaced it. Everything else - including a
// member of a block that was left alone - keeps the application's spelling.
// Storage first, pointers after: xfbNames holds pointers into these strings.
//
// Same rule for a demoted gl_PointSize: the capture stage's ESSL no longer
// spells the built-in at all - the value lives in the carrier the demotion
// named - so the driver-side request has to follow it there. Only when the
// capture stage IS a demoted one (geometry, else evaluation): a program whose
// capture stage is the vertex shader keeps the built-in and its spelling,
// whatever happened to a control stage behind it.
Bool captureStageDemoted = false;
if (stateProgramObject->PointSizeDemoted()) {
for (const ShaderStage linkedStage : linkedStages) {
if (linkedStage == ShaderStage::TessEval || linkedStage == ShaderStage::Geometry) {
captureStageDemoted = true;
break;
}
}
}
Vector<String> rewrittenXfbNames(xfbVaryings.size());
for (SizeT nameIndex = 0; nameIndex < xfbVaryings.size(); ++nameIndex) {
String flatName;
@@ -8019,6 +8044,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_Util::ShaderTranspiler::ShaderCompiler::RewriteXfbCaptureNameForFlattenedBlock(
xfbVaryings[nameIndex].name, flattenedXfbBlockNames, flatName)) {
rewrittenXfbNames[nameIndex] = std::move(flatName);
} else if (captureStageDemoted && xfbVaryings[nameIndex].name == "gl_PointSize") {
rewrittenXfbNames[nameIndex] =
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME;
} else {
rewrittenXfbNames[nameIndex] = xfbVaryings[nameIndex].name;
}
@@ -1081,6 +1081,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// report VK_FALSE, so on every real mobile device this is false and the demotion runs
// exactly as it always has.
m_dynamicParameters.SupportsShaderFloat64 = m_vulkanCaps.SupportsShaderFloat64;
// shaderTessellationAndGeometryPointSize, both stage families from the one feature.
// False arms the shared phase-B point-size demotion, whose modules then carry no
// TessellationPointSize/GeometryPointSize capability and build without the feature.
// MOBILEGL_POINT_SIZE_DEMOTION=1 pretends it is absent so the demotion can be
// exercised on a healthy driver (lavapipe advertises the feature); =0 restores the
// detected answer's declines.
{
Bool supportsStagePointSize = m_vulkanCaps.SupportsTessellationAndGeometryPointSize;
switch (MG_Config::Features.PointSizeDemotion) {
case MG_Config::QuirkOverride::ForceOn:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=1 - treating tessellation/geometry "
"gl_PointSize as unhosted so the demotion runs on this driver");
supportsStagePointSize = false;
break;
case MG_Config::QuirkOverride::ForceOff:
MGLOG_I("DirectVulkan: MOBILEGL_POINT_SIZE_DEMOTION=0 - keeping the built-in and the "
"plain declines regardless of the device feature");
supportsStagePointSize = true;
break;
case MG_Config::QuirkOverride::Auto:
break;
}
m_dynamicParameters.SupportsTessellationPointSize = supportsStagePointSize;
m_dynamicParameters.SupportsGeometryPointSize = supportsStagePointSize;
}
// Never, on any device, and DELIBERATELY NOT COUPLED to the line above even though it
// once tracked the same feature. It used to, because a `dvec` input needed Float64 to
// exist in the module at all; a 64-bit vertex FETCH was already impossible
@@ -1428,6 +1428,22 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
if (varying.name == "gl_PointSize") {
// A demoted module (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) no longer ACCESSES the
// built-in member - the value lives in the carrier variable the demotion
// named - so the capture binds to the carrier directly. The mirror below
// must not run for it: reading the now-unwritten member would capture
// garbage, and the read itself is the capability access the demotion
// exists to remove. Detected off the module's own debug names, so a
// composite built from another program's stage answers for the module it
// actually contains.
const auto carrierIt = idsByName.find(
MG_Util::ShaderTranspiler::ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME);
if (carrierIt != idsByName.end()) {
decorateForXfb(carrierIt->second, varying.bufferIndex, varying.offsetBytes);
modified = true;
continue;
}
needsPointSizeMirror = true;
pointSizeBufferIndex = varying.bufferIndex;
pointSizeOffset = varying.offsetBytes;
@@ -3454,6 +3470,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
if (program.PointSizeDemoted()) {
// THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that pins
// MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because every
// rendering assertion above it stays green on a healthy driver whether the
// demotion ran or was silently disarmed. See PointSizeDemotionScenario.
MGLOG_I_ONCE("DirectVulkan is building programs whose tessellation/geometry gl_PointSize was "
"demoted to an ordinary varying, because this device cannot host the built-in "
"in those stages.");
}
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
// Unconditional now: the two ValidateTransformedSpirv calls below run in every build,
@@ -896,6 +896,11 @@ namespace MobileGL::MG_State::GLState {
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
// The second and third capability bits, under exactly the same rule: each arms a
// phase-B rewrite of the cached modules (the point-size demotion), read from the
// same env snapshot that phase B will consult, so key and bytes cannot disagree.
keyInputs.demoteTessellationPointSize = env.DemotesTessellationPointSize();
keyInputs.demoteGeometryPointSize = env.DemotesGeometryPointSize();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
@@ -603,6 +603,11 @@ namespace MobileGL::MG_State::GLState {
// other question about the global UBO's layout - and it is one: it decides how wide a
// `double` uniform's slot is.
Bool UsesNativeFloat64() const { return Spirv().nativeFloat64; }
// Whether gl_PointSize was demoted out of this program's tessellation/geometry
// modules into the ordinary carrier varying. Joins phase B: it is a fact about the
// generated modules, and its readers (the backends' capture-name respelling) already
// hold the phase-B join.
Bool PointSizeDemoted() const { return Spirv().pointSizeDemoted; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
@@ -1429,6 +1434,18 @@ namespace MobileGL::MG_State::GLState {
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
// Whether gl_PointSize was demoted out of THESE modules' tessellation/geometry
// stages into an ordinary varying (ShaderCompiler::
// DemoteTessellationGeometryPointSizeForProgram) because the backend cannot host
// the built-in there. Per PROGRAM by construction - a consumer whose producer
// kept the built-in would read garbage - and recorded here rather than
// re-derived because it cannot be: the rewrite's whole point is that the final
// bytes no longer declare the capability that armed it. The backends read it to
// respell a "gl_PointSize" transform-feedback capture as the carrier
// (ShaderCompiler::POINT_SIZE_CAPTURE_CARRIER_NAME). The GL reflection surface
// deliberately keeps answering "gl_PointSize": demotion happens after phase A,
// so every query keeps the truthful GL spelling.
Bool pointSizeDemoted = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -128,8 +128,15 @@ namespace MobileGL::MG_State::GLState {
// with (ProgramLinkTask::BuildSpirvCacheKey reads the same env) or a memo written under
// one answer could be handed back under the other.
const Bool nativeFloat64 = m_phaseA->in.env != nullptr && m_phaseA->in.env->ConsumesFloat64Natively();
// The point-size demotion verdicts, read from the SAME snapshot for the same reason
// - and the same bits BuildSpirvCacheKey put in the L1 key, so a memo written under
// one answer can never be handed back under the other.
const Bool demoteTessellationPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesTessellationPointSize();
const Bool demoteGeometryPointSize =
m_phaseA->in.env != nullptr && m_phaseA->in.env->DemotesGeometryPointSize();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
nativeFloat64, demoteTessellationPointSize, demoteGeometryPointSize);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
@@ -188,7 +195,9 @@ namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation, const Bool nativeFloat64) {
const Bool enableSpirvValidation, const Bool nativeFloat64,
const Bool demoteTessellationPointSize,
const Bool demoteGeometryPointSize) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -267,6 +276,44 @@ namespace MobileGL::MG_State::GLState {
}
}
artifacts.spirvStatus = allOptimized;
// The point-size demotion, program-wide and after the sanitize chain, so it works
// on the final shared bytes both backends consume and nothing downstream can trim
// the carriers it declares. Only the env half of the verdict lives here (and in the
// L1 key); whether the program actually declares the capability is probed inside,
// so the common case on an affected device - a program that never touches point
// size in those stages - pays one module parse per stage and no rewrite.
artifacts.pointSizeDemoted = false;
if (allOptimized && (demoteTessellationPointSize || demoteGeometryPointSize)) {
Bool captureRequestsPointSize = false;
for (const auto& varying : handoff.reflection.xfbVaryings) {
if (varying.name == "gl_PointSize") {
captureRequestsPointSize = true;
break;
}
}
ShaderCompiler::PointSizeDemotionOutcome outcome;
if (!ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
artifacts.generatedSpirv, handoff.shaderTypes, demoteTessellationPointSize,
demoteGeometryPointSize, captureRequestsPointSize, outcome,
!deferOutputValidationForDirectVulkan, enableSpirvValidation)) {
// Optimizer failure: modules untouched, so the capability is still declared
// and the backends' existing refusals stay in charge - honest, just slower.
DeferLog(std::format("ProgramObject {}: point-size demotion failed in the optimizer; the "
"program keeps its built-in and the device's declines apply",
externalIndex));
} else if (outcome.demoted) {
artifacts.pointSizeDemoted = true;
DeferLog(std::format("ProgramObject {}: gl_PointSize demoted to an ordinary varying across "
"the tessellation/geometry chain (value preserved for capture and "
"gl_in reads; rasterized size falls back to 1.0)",
externalIndex));
} else if (!outcome.declineDetail.empty()) {
DeferLog(std::format("ProgramObject {}: point-size demotion declined ({}); the program "
"keeps its built-in and the device's declines apply",
externalIndex, outcome.declineDetail));
}
}
}
void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff,
@@ -67,7 +67,8 @@ namespace MobileGL::MG_State::GLState {
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
Bool nativeFloat64, Bool demoteTessellationPointSize,
Bool demoteGeometryPointSize);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as
@@ -222,6 +222,12 @@ namespace MobileGL::MG_Util::BackendLoader {
vkGetPhysicalDeviceFeatures(physicalDevice, &supportedFeatures);
caps.SupportsWideLines = supportedFeatures.wideLines == VK_TRUE;
caps.SupportsShaderFloat64 = supportedFeatures.shaderFloat64 == VK_TRUE;
// One feature covers both stage families here, unlike the ES loader's two extension
// tiers; the renderer enables it on the device whenever advertised
// (VulkanRenderer::CreateLogicalDeviceAndQueues), so this probe and that enable can
// never disagree about the physical device.
caps.SupportsTessellationAndGeometryPointSize =
supportedFeatures.shaderTessellationAndGeometryPointSize == VK_TRUE;
caps.SupportsImageCubeArray = supportedFeatures.imageCubeArray == VK_TRUE;
{
// Probe the formats a colour render target actually uses. A driver that refuses the flag
@@ -350,6 +356,7 @@ namespace MobileGL::MG_Util::BackendLoader {
FillFragmentInterpolationLimits(caps, properties.limits);
caps.SupportsWideLines = false;
caps.SupportsShaderFloat64 = false;
caps.SupportsTessellationAndGeometryPointSize = false;
caps.SupportsImageCubeArray = false;
caps.Supports2DArrayCompatible3DImages = false;
// This helper only receives properties, not VkPhysicalDeviceFeatures. Leave optional
@@ -87,6 +87,13 @@ namespace MobileGL {
// needs it, which includes every 64-bit vertex attribute: the attribute itself arrives
// as 32-bit words, but the bitcast result and everything computed from it is Float64.
Bool SupportsShaderFloat64 = false;
// VkPhysicalDeviceFeatures::shaderTessellationAndGeometryPointSize. Any
// tessellation/geometry module declaring OpCapability TessellationPointSize /
// GeometryPointSize needs it (VUID-VkShaderModuleCreateInfo-pCode-08740's
// capability table); without it the shared phase-B chain demotes the built-in
// to an ordinary varying. One feature for both stage families, unlike the ES
// loader's two extension tiers.
Bool SupportsTessellationAndGeometryPointSize = false;
// VkPhysicalDeviceFeatures::imageCubeArray. Required before a
// VK_IMAGE_VIEW_TYPE_CUBE_ARRAY view may be created at all
// (VUID-VkImageViewCreateInfo-viewType-01004), which is every cube map array texture -
@@ -154,6 +154,20 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// demoted module is the one that works everywhere, so it is what a standalone compile
// (an internal shader object, a unit test) gets.
Bool ConsumesFloat64Natively() const { return HasBackend() && params.SupportsShaderFloat64; }
// Whether the tessellation / geometry gl_PointSize demotion is ARMED for this env -
// i.e. the backend declared it cannot host the capability. Deliberately requiring a
// backend, opposite in shape to ConsumesFloat64Natively's fallback but for the same
// conservatism: the fp64 demotion is the module that works everywhere, while this
// one rewrites interfaces and capture names, so the no-backend answer (standalone
// compiles, unit tests) is the untouched module. Like nativeFloat64, each bit is L1
// key material of its own (SpirvTranslationKeyInputs), never part of the frontend
// fingerprint: glslang produces the same thing either way.
Bool DemotesTessellationPointSize() const {
return HasBackend() && !params.SupportsTessellationPointSize;
}
Bool DemotesGeometryPointSize() const {
return HasBackend() && !params.SupportsGeometryPointSize;
}
// Matches the historical rule exactly: with no active backend every extension counts
// as advertised, because the frontend then has nothing to gate against.
Bool IsExtensionAdvertised(GLExtension extension) const {
@@ -52,6 +52,7 @@
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeResourceArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "SpirvPasses/DemotePointSizePass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -816,6 +817,300 @@ namespace MobileGL {
return false;
}
namespace {
namespace opt_analysis = spvtools::opt::analysis;
// Locations one value of `type` consumes (GL 4.6 core 11.1.2.1). Unknown
// shapes OVERESTIMATE (4) rather than fail: this feeds the free-location
// choice for the demoted point-size carrier, where an overestimate wastes a
// couple of slots and an underestimate aliases a live varying.
Uint32 ConservativeLocationSpan(const opt_analysis::Type* type) {
constexpr Uint32 kUnknownSpan = 4;
if (type == nullptr) return kUnknownSpan;
if (type->AsFloat() != nullptr || type->AsInteger() != nullptr ||
type->AsBool() != nullptr) {
return 1u;
}
if (const auto* vector = type->AsVector()) {
const auto* elementFloat = vector->element_type()->AsFloat();
const Bool is64Bit = elementFloat != nullptr && elementFloat->width() == 64;
return (is64Bit && vector->element_count() > 2) ? 2u : 1u;
}
if (const auto* matrix = type->AsMatrix()) {
return ConservativeLocationSpan(matrix->element_type()) * matrix->element_count();
}
if (const auto* array = type->AsArray()) {
const auto& lengthWords = array->length_info().words;
if (lengthWords.size() != 2 ||
lengthWords[0] !=
static_cast<Uint32>(opt_analysis::Array::LengthInfo::kConstant)) {
return kUnknownSpan;
}
return ConservativeLocationSpan(array->element_type()) * std::max(lengthWords[1], 1u);
}
if (const auto* strct = type->AsStruct()) {
Uint32 sum = 0;
for (const auto* member : strct->element_types()) {
sum += ConservativeLocationSpan(member);
}
return std::max(sum, 1u);
}
return kUnknownSpan;
}
// One BuildModule per module answers all three questions the program-scoped
// demotion driver asks: which point-size capability the module declares, and
// one past the highest Input/Output location slot it consumes (so the carrier
// can be placed beyond every varying of every stage).
struct PointSizeModuleProbe {
Bool parsed = false;
Bool declaresTessellationPointSize = false;
Bool declaresGeometryPointSize = false;
Uint32 locationSlotEnd = 0;
};
PointSizeModuleProbe ProbePointSizeModule(const Vector<Uint32>& spirv) {
PointSizeModuleProbe probe;
if (spirv.empty()) {
probe.parsed = true; // an absent stage constrains nothing
return probe;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ProbePointSizeModule"), spirv.data(),
spirv.size());
if (!context) return probe;
probe.parsed = true;
for (const spvtools::opt::Instruction& capability : context->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize) {
probe.declaresTessellationPointSize = true;
} else if (declared == spv::Capability::GeometryPointSize) {
probe.declaresGeometryPointSize = true;
}
}
spv::ExecutionModel model = spv::ExecutionModel::Max;
for (spvtools::opt::Instruction& entryPoint : context->module()->entry_points()) {
model = static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0));
break;
}
// Per-vertex interfaces are arrayed one level deeper than the locations
// they consume; peel that level, but never off a per-patch output.
const Bool peelInputs = model == spv::ExecutionModel::TessellationControl ||
model == spv::ExecutionModel::TessellationEvaluation ||
model == spv::ExecutionModel::Geometry;
const Bool peelOutputs = model == spv::ExecutionModel::TessellationControl;
std::unordered_set<Uint32> patchDecorated;
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 2 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Patch) {
patchDecorated.insert(annotation.GetSingleWordInOperand(0));
}
}
auto* defUse = context->get_def_use_mgr();
auto* typeMgr = context->get_type_mgr();
for (spvtools::opt::Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Location) {
const Uint32 location = annotation.GetSingleWordInOperand(2);
Uint32 span = 1;
spvtools::opt::Instruction* var =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
const auto storage =
static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
// Two location namespaces are NOT varying slots and must not
// shrink the carrier budget: vertex-stage inputs (attribute
// locations) and fragment-stage outputs (draw buffers).
if ((model == spv::ExecutionModel::Vertex &&
storage == spv::StorageClass::Input) ||
(model == spv::ExecutionModel::Fragment &&
storage == spv::StorageClass::Output)) {
continue;
}
spvtools::opt::Instruction* pointerType = defUse->GetDef(var->type_id());
if (pointerType != nullptr &&
pointerType->opcode() == spv::Op::OpTypePointer) {
const opt_analysis::Type* pointee =
typeMgr->GetType(pointerType->GetSingleWordInOperand(1));
const Bool peel =
((storage == spv::StorageClass::Input && peelInputs) ||
(storage == spv::StorageClass::Output && peelOutputs)) &&
patchDecorated.count(var->result_id()) == 0;
if (peel && pointee != nullptr && pointee->AsArray() != nullptr) {
pointee = pointee->AsArray()->element_type();
}
span = ConservativeLocationSpan(pointee);
}
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
} else if (annotation.opcode() == spv::Op::OpMemberDecorate &&
annotation.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::Location) {
const Uint32 member = annotation.GetSingleWordInOperand(1);
const Uint32 location = annotation.GetSingleWordInOperand(3);
Uint32 span = 1;
spvtools::opt::Instruction* structType =
defUse->GetDef(annotation.GetSingleWordInOperand(0));
if (structType != nullptr && structType->opcode() == spv::Op::OpTypeStruct &&
member < structType->NumInOperands()) {
span = ConservativeLocationSpan(
typeMgr->GetType(structType->GetSingleWordInOperand(member)));
}
probe.locationSlotEnd = std::max(probe.locationSlotEnd, location + span);
}
}
return probe;
}
// Interior boundary carrier names, spelled by the PRODUCING stage so both
// sides of one boundary agree textually as well as by location. The capture
// stage's output uses POINT_SIZE_CAPTURE_CARRIER_NAME instead. None of these
// may embed the token "gl_PointSize" - see the constant's comment.
const char* PointSizeBoundaryCarrierName(const GLenum producerStage) {
switch (producerStage) {
case GL_VERTEX_SHADER:
return "mg_PointSizeIo0";
case GL_TESS_CONTROL_SHADER:
return "mg_PointSizeIo1";
case GL_TESS_EVALUATION_SHADER:
return "mg_PointSizeIo2";
default:
return "mg_PointSizeIo0";
}
}
// Past this the carrier would sit above what a minimum-spec varying budget can
// address; such a program keeps its honest decline instead.
constexpr Uint32 kMaxDemotedPointSizeCarrierLocation = 30;
} // namespace
Bool ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
const Bool demoteTessellation, const Bool demoteGeometry,
const Bool captureRequestsPointSize, PointSizeDemotionOutcome& outcome,
const bool validateOutput, const bool enableSpirvValidation) {
outcome = {};
if (!demoteTessellation && !demoteGeometry) return true;
// The pre-rasterization chain, in pipeline order, as indices into `modules`.
Int stageIndex[4] = {-1, -1, -1, -1}; // VS, TCS, TES, GS
for (SizeT i = 0; i < shaderTypes.size() && i < modules.size(); ++i) {
switch (shaderTypes[i]) {
case GL_VERTEX_SHADER: stageIndex[0] = static_cast<Int>(i); break;
case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast<Int>(i); break;
case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast<Int>(i); break;
case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast<Int>(i); break;
default: break;
}
}
if (stageIndex[1] < 0 && stageIndex[2] < 0 && stageIndex[3] < 0) return true;
// One probe per module: the capability facts arm the verdict, the location
// scan places the carrier past every varying of every stage (the location is
// shared program-wide, so it has to clear all of them at once).
Bool anyTessellationUse = false;
Bool anyGeometryUse = false;
Uint32 carrierLocation = 0;
for (const auto& module : modules) {
const PointSizeModuleProbe probe = ProbePointSizeModule(module);
if (!probe.parsed) {
// Unparseable is not a verdict; the module is already broken for
// other reasons and owns its own failure.
return true;
}
anyTessellationUse |= probe.declaresTessellationPointSize;
anyGeometryUse |= probe.declaresGeometryPointSize;
carrierLocation = std::max(carrierLocation, probe.locationSlotEnd);
}
if (!((anyTessellationUse && demoteTessellation) ||
(anyGeometryUse && demoteGeometry))) {
return true;
}
if (carrierLocation > kMaxDemotedPointSizeCarrierLocation) {
outcome.declineDetail = std::format(
"the program's varyings already reach location {}, past the carrier budget",
carrierLocation);
return true;
}
// GL 4.6 core 13.3: capture reads the last capture-capable stage - geometry,
// else evaluation, else the vertex stage (whose built-in needs no demotion).
const Int captureStage = stageIndex[3] >= 0 ? 3 : (stageIndex[2] >= 0 ? 2 : -1);
constexpr GLenum kStageEnum[4] = {GL_VERTEX_SHADER, GL_TESS_CONTROL_SHADER,
GL_TESS_EVALUATION_SHADER, GL_GEOMETRY_SHADER};
// Back to front, so each stage's "I now read the carrier" report can force the
// producing stage's output carrier into existence - Vulkan requires every
// consumed input to be produced (VUID-RuntimeSpirv-OpEntryPoint-08743), and an
// ES link may reject a statically read input with no producing output.
Vector<Vector<Uint32>> rewritten(modules.size());
Bool rewrote[4] = {false, false, false, false};
Bool forceOutput[4] = {false, false, false, false};
if (captureStage >= 0 && captureRequestsPointSize) {
forceOutput[captureStage] = true;
}
for (Int stage = 3; stage >= 0; --stage) {
const Int moduleIndex = stageIndex[stage];
if (moduleIndex < 0) continue;
Int producer = stage - 1;
while (producer >= 0 && stageIndex[producer] < 0) --producer;
DemotePointSizeOptions options;
options.location = carrierLocation;
options.inputCarrierName = PointSizeBoundaryCarrierName(
producer >= 0 ? kStageEnum[producer]
// A separable program whose first present stage already
// consumes the carrier: the producer lives in another
// program. Name by the conventional producer of this
// stage's boundary; matching across programs is by
// location and is documented residue either way.
: kStageEnum[stage > 0 ? stage - 1 : 0]);
options.outputCarrierName = stage == captureStage
? String(POINT_SIZE_CAPTURE_CARRIER_NAME)
: String(PointSizeBoundaryCarrierName(kStageEnum[stage]));
options.forceOutputCarrier = forceOutput[stage];
// A vertex stage with nothing downstream consuming the carrier needs no
// mirror and stays byte-identical without an optimizer round trip.
if (stage == 0 && !options.forceOutputCarrier) continue;
DemotePointSizeReport report;
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
DemotePointSizePass::CreateDemotePointSizePass(options, &report));
if (!RunOptimizerChecked("DemoteTessellationGeometryPointSizeForProgram", optimizer,
modules[moduleIndex], rewritten[moduleIndex],
validateOutput, enableSpirvValidation)) {
return false; // modules untouched: nothing was committed
}
if (report.declined) {
outcome.declineDetail = Move(report.declineReason);
return true; // byte-identical decline; the existing refusals stay armed
}
rewrote[stage] = true;
if (report.createdInputCarrier && producer >= 0) {
forceOutput[producer] = true;
}
}
// Atomic commit: every stage rewritten together or none at all.
for (Int stage = 0; stage < 4; ++stage) {
if (!rewrote[stage]) continue;
modules[stageIndex[stage]] = Move(rewritten[stageIndex[stage]]);
}
outcome.demoted = true;
return true;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
@@ -562,6 +562,53 @@ namespace MobileGL {
// the module parse costs nothing on a device that has it.
static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector<Uint32>& spirv);
// ---- gl_PointSize demotion for devices without the capability above ----
// The name of the demoted program's LAST capture-capable stage's point-size
// carrier. It is the contract three parties meet at: the demotion pass names
// the variable, DirectVulkan's XfbCaptureDecoratePass binds a "gl_PointSize"
// capture to it instead of mirroring the (no longer accessed) built-in, and
// DirectGLES respells the driver-side glTransformFeedbackVaryings request
// with it. Deliberately NOT containing the substring "gl_PointSize":
// DirectGLES's extension-request gate is a text search for that token over
// the emitted ESSL, and a carrier name embedding it would re-arm the decline
// this demotion exists to retire.
static constexpr const char* POINT_SIZE_CAPTURE_CARRIER_NAME = "mg_PointSizeCapture";
// What the program-scoped demotion left behind. `demoted` false with an empty
// detail means the program never needed it (no tessellation/geometry stage
// accesses the built-in, or the device hosts it); false WITH a detail means a
// module shape the pass cannot express - the modules are byte-identical and
// the existing decline paths (Espryt's missing-extension compile failure,
// Magma's pointSizeCapabilityUnsupported refusal) stay in charge of it.
struct PointSizeDemotionOutcome {
Bool demoted = false;
String declineDetail;
};
// Demotes gl_PointSize across a WHOLE program's pre-rasterization chain into
// ordinary float varyings at one shared free location, so a device that
// advertises neither ES tessellation/geometry_point_size extension nor
// Vulkan's shaderTessellationAndGeometryPointSize can still run programs
// whose tessellation/geometry stages merely CARRY the value (transform
// feedback and gl_in[].gl_PointSize reads). Runs after
// SanitizeAndOptimizeBinary, on the final shared modules both backends
// consume, and is atomic per program: every stage is rewritten or none is,
// because a consumer whose producer kept the built-in would read garbage.
// `demoteTessellation` / `demoteGeometry` are the env verdicts (the device
// LACKS that capability); the per-program half of the decision - whether any
// module actually declares TessellationPointSize / GeometryPointSize - is
// probed here. `captureRequestsPointSize` forces the capture-capable last
// stage to declare its carrier even when it never writes the built-in, so a
// by-name capture always has something to bind to. Returns false only when
// the optimizer itself failed (modules untouched); a shape decline is
// reported through `outcome` and also leaves the modules untouched. See
// DemotePointSizePass for the per-module rewrite and its honest residue.
static Bool DemoteTessellationGeometryPointSizeForProgram(
Vector<Vector<Uint32>>& modules, const Vector<GLenum>& shaderTypes,
Bool demoteTessellation, Bool demoteGeometry, Bool captureRequestsPointSize,
PointSizeDemotionOutcome& outcome, bool validateOutput = true,
bool enableSpirvValidation = false);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the
// module (see its header for the two operations that make it decline), which is
@@ -0,0 +1,616 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "DemotePointSizePass.h"
#include "spirv.hpp"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include "source/util/string_utils.h"
#include <format>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
spv::ExecutionModel EntryExecutionModel(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return static_cast<spv::ExecutionModel>(ep.GetSingleWordInOperand(0));
}
return spv::ExecutionModel::Max;
}
Instruction* EntryPoint(IRContext* ctx) {
for (Instruction& ep : ctx->module()->entry_points()) {
return &ep;
}
return nullptr;
}
// OpTypePointer <storage-class> <pointee>
uint32_t VariablePointeeType(IRContext* ctx, Instruction* var) {
Instruction* ptrType = ctx->get_def_use_mgr()->GetDef(var->type_id());
if (ptrType == nullptr || ptrType->opcode() != spv::Op::OpTypePointer) return 0;
return ptrType->GetSingleWordInOperand(1);
}
bool IsFloat32Type(IRContext* ctx, uint32_t typeId) {
Instruction* t = ctx->get_def_use_mgr()->GetDef(typeId);
return t != nullptr && t->opcode() == spv::Op::OpTypeFloat &&
t->NumInOperands() >= 1 && t->GetSingleWordInOperand(0) == 32;
}
// The value of a plain 32-bit OpConstant, or false (spec constants and anything
// else make the caller decline rather than guess).
bool PlainConstantValue(IRContext* ctx, uint32_t id, uint32_t& outValue) {
Instruction* def = ctx->get_def_use_mgr()->GetDef(id);
if (def == nullptr || def->opcode() != spv::Op::OpConstant) return false;
if (def->NumInOperands() != 1) return false;
outValue = def->GetSingleWordInOperand(0);
return true;
}
uint32_t Float32Type(IRContext* ctx) {
analysis::Float f(32);
return ctx->get_type_mgr()->GetTypeInstruction(&f);
}
// An OpTypeArray of float32 with the given length constant, reusing an existing
// declaration when one exists.
uint32_t ArrayOfFloat32Type(IRContext* ctx, uint32_t lengthConstId, uint32_t lengthValue) {
analysis::Float f(32);
analysis::Type* floatReg = ctx->get_type_mgr()->GetRegisteredType(&f);
const analysis::Array::LengthInfo lengthInfo{
lengthConstId,
{static_cast<uint32_t>(analysis::Array::LengthInfo::kConstant), lengthValue}};
analysis::Array arr(floatReg, lengthInfo);
return ctx->get_type_mgr()->GetTypeInstruction(&arr);
}
void AddNameFor(IRContext* ctx, uint32_t id, const String& name) {
std::vector<Operand> operands;
operands.push_back({SPV_OPERAND_TYPE_ID, {id}});
operands.push_back(
{SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(name)});
ctx->AddDebug2Inst(
spvtools::MakeUnique<Instruction>(ctx, spv::Op::OpName, 0, 0, operands));
}
void AddLocationDecoration(IRContext* ctx, uint32_t id, uint32_t location) {
ctx->AddAnnotationInst(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpDecorate, 0, 0,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {id}},
{SPV_OPERAND_TYPE_DECORATION,
{static_cast<uint32_t>(spv::Decoration::Location)}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {location}}}));
}
// A fresh interface variable: declared, named, located, listed on the entry
// point, and registered with the def-use manager so ReplaceAllUsesWith may name
// it before the end-of-pass invalidation.
uint32_t CreateCarrierVariable(IRContext* ctx, Instruction* entryPoint, uint32_t pointeeTypeId,
spv::StorageClass storage, const String& name,
uint32_t location) {
const uint32_t ptrTypeId = ctx->get_type_mgr()->FindPointerToType(pointeeTypeId, storage);
if (ptrTypeId == 0) return 0;
const uint32_t varId = ctx->TakeNextId();
auto var = spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpVariable, ptrTypeId, varId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<uint32_t>(storage)}}});
Instruction* varInst = var.get();
ctx->AddGlobalValue(std::move(var));
ctx->get_def_use_mgr()->AnalyzeInstDefUse(varInst);
AddNameFor(ctx, varId, name);
AddLocationDecoration(ctx, varId, location);
entryPoint->AddOperand({SPV_OPERAND_TYPE_ID, {varId}});
return varId;
}
} // namespace
spvtools::opt::Pass::Status DemotePointSizePass::Process() {
auto* ctx = context();
auto* defUse = ctx->get_def_use_mgr();
const spv::ExecutionModel model = EntryExecutionModel(ctx);
Instruction* entryPoint = EntryPoint(ctx);
if (entryPoint == nullptr) return Status::SuccessWithoutChange;
const bool isVertex = model == spv::ExecutionModel::Vertex;
const bool isTessControl = model == spv::ExecutionModel::TessellationControl;
const bool isTessEval = model == spv::ExecutionModel::TessellationEvaluation;
const bool isGeometry = model == spv::ExecutionModel::Geometry;
if (!isVertex && !isTessControl && !isTessEval && !isGeometry) {
return Status::SuccessWithoutChange;
}
const auto decline = [&](String reason) {
if (m_report != nullptr) {
m_report->declined = true;
m_report->declineReason = Move(reason);
}
return Status::SuccessWithoutChange;
};
// ---- discovery: where does PointSize live in this module ------------------
// Member form: every struct type with a member decorated BuiltIn PointSize.
struct MemberSite {
uint32_t structId = 0;
uint32_t memberIndex = 0;
};
std::vector<MemberSite> memberSites;
// Standalone form: a variable decorated BuiltIn PointSize directly.
std::vector<Instruction*> standaloneVars;
std::vector<Instruction*> standaloneBuiltInDecorations;
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(3)) ==
spv::BuiltIn::PointSize) {
memberSites.push_back({ann.GetSingleWordInOperand(0), ann.GetSingleWordInOperand(1)});
} else if (ann.opcode() == spv::Op::OpDecorate && ann.NumInOperands() >= 3 &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) ==
spv::Decoration::BuiltIn &&
static_cast<spv::BuiltIn>(ann.GetSingleWordInOperand(2)) ==
spv::BuiltIn::PointSize) {
Instruction* var = defUse->GetDef(ann.GetSingleWordInOperand(0));
if (var != nullptr && var->opcode() == spv::Op::OpVariable) {
standaloneVars.push_back(var);
standaloneBuiltInDecorations.push_back(&ann);
}
}
}
const auto memberIndexIn = [&](uint32_t structId, uint32_t& outMember) {
for (const MemberSite& site : memberSites) {
if (site.structId == structId) {
outMember = site.memberIndex;
return true;
}
}
return false;
};
// The gl_PerVertex-shaped interface variables: Input/Output variables whose
// pointee is (an array of) a struct carrying a PointSize member.
struct BlockVar {
Instruction* var = nullptr;
spv::StorageClass storage = spv::StorageClass::Output;
bool arrayed = false;
uint32_t arrayLengthConstId = 0;
uint32_t arrayLengthValue = 0;
uint32_t memberIndex = 0;
};
std::vector<BlockVar> blockVars;
for (Instruction& inst : ctx->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) continue;
const auto storage = static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
uint32_t pointeeId = VariablePointeeType(ctx, &inst);
if (pointeeId == 0) continue;
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee == nullptr) continue;
BlockVar entry;
entry.var = &inst;
entry.storage = storage;
if (pointee->opcode() == spv::Op::OpTypeArray) {
entry.arrayed = true;
entry.arrayLengthConstId = pointee->GetSingleWordInOperand(1);
if (!PlainConstantValue(ctx, entry.arrayLengthConstId, entry.arrayLengthValue)) {
continue; // spec-constant-sized interface array: not glslang's shape
}
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
if (pointee == nullptr) continue;
}
if (pointee->opcode() != spv::Op::OpTypeStruct) continue;
if (!memberIndexIn(pointee->result_id(), entry.memberIndex)) continue;
blockVars.push_back(entry);
}
// ---- vertex stage: mirror, never demote -----------------------------------
if (isVertex) {
if (!m_options.forceOutputCarrier) return Status::SuccessWithoutChange;
if (m_options.outputCarrierName.empty()) {
return decline("vertex mirror requested without a carrier name");
}
const uint32_t floatTypeId = Float32Type(ctx);
// The source of the mirrored value: the output block's PointSize member,
// a standalone output variable, or - with neither declared - the constant
// 1.0 GL's default point size names.
Instruction* blockVar = nullptr;
uint32_t memberIndex = 0;
for (const BlockVar& candidate : blockVars) {
if (candidate.storage == spv::StorageClass::Output && !candidate.arrayed) {
blockVar = candidate.var;
memberIndex = candidate.memberIndex;
break;
}
}
Instruction* standaloneOut = nullptr;
for (Instruction* candidate : standaloneVars) {
if (static_cast<spv::StorageClass>(candidate->GetSingleWordInOperand(0)) ==
spv::StorageClass::Output &&
IsFloat32Type(ctx, VariablePointeeType(ctx, candidate))) {
standaloneOut = candidate;
break;
}
}
const uint32_t carrierId =
CreateCarrierVariable(ctx, entryPoint, floatTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
if (carrierId == 0) return decline("could not declare the vertex mirror carrier");
uint32_t memberConstId = 0;
uint32_t ptrOutputFloatId = 0;
if (blockVar != nullptr) {
memberConstId = ctx->get_constant_mgr()->GetSIntConstId(
static_cast<int32_t>(memberIndex));
ptrOutputFloatId =
ctx->get_type_mgr()->FindPointerToType(floatTypeId, spv::StorageClass::Output);
if (ptrOutputFloatId == 0) return decline("no Output float pointer type");
}
uint32_t defaultOneId = 0;
if (blockVar == nullptr && standaloneOut == nullptr) {
defaultOneId = ctx->get_constant_mgr()->GetFloatConstId(1.0f);
}
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
bool mirrored = false;
for (auto funcIt = ctx->module()->begin(); funcIt != ctx->module()->end(); ++funcIt) {
if (funcIt->result_id() != entryFunctionId) continue;
funcIt->ForEachInst([&](Instruction* inst) {
if (inst->opcode() != spv::Op::OpReturn &&
inst->opcode() != spv::Op::OpReturnValue) {
return;
}
uint32_t valueId = 0;
if (blockVar != nullptr) {
const uint32_t chainId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpAccessChain, ptrOutputFloatId, chainId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {blockVar->result_id()}},
{SPV_OPERAND_TYPE_ID, {memberConstId}}}));
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {chainId}}}));
} else if (standaloneOut != nullptr) {
valueId = ctx->TakeNextId();
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpLoad, floatTypeId, valueId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_ID, {standaloneOut->result_id()}}}));
} else {
valueId = defaultOneId;
}
inst->InsertBefore(spvtools::MakeUnique<Instruction>(
ctx, spv::Op::OpStore, 0, 0,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {carrierId}},
{SPV_OPERAND_TYPE_ID, {valueId}}}));
mirrored = true;
});
}
if (!mirrored) {
// An entry function with no return is not a module glslang produces;
// the carrier stays declared (the consumer's read is undefined, as an
// unwritten built-in's would have been).
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
// ---- tessellation / geometry: redirect and strip --------------------------
// Phase 1: ANALYSIS ONLY. Every plan is collected before anything mutates, so
// a decline leaves the module byte-identical.
struct ArrayedRedirect {
Instruction* chain = nullptr;
bool input = false;
};
std::vector<ArrayedRedirect> arrayedRedirects; // gl_in[i].ps / gl_out[i].ps
std::vector<Instruction*> scalarOutputChains; // non-arrayed out block's member
const BlockVar* arrayedInput = nullptr;
const BlockVar* arrayedOutput = nullptr;
for (const BlockVar& blockVar : blockVars) {
if (blockVar.arrayed) {
if (blockVar.storage == spv::StorageClass::Input) {
arrayedInput = &blockVar;
} else {
arrayedOutput = &blockVar;
}
}
bool declined = false;
String reason;
defUse->ForEachUser(blockVar.var, [&](Instruction* user) {
if (declined) return;
switch (user->opcode()) {
case spv::Op::OpEntryPoint:
case spv::Op::OpName:
case spv::Op::OpDecorate:
return;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain: {
const uint32_t indexCount = user->NumInOperands() - 1;
if (!blockVar.arrayed) {
if (indexCount < 1) {
declined = true;
reason = "an index-less pointer to the whole gl_PerVertex block";
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(1), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 1) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
scalarOutputChains.push_back(user);
return;
}
// Arrayed (gl_in / gl_out): [vertex, member, ...].
if (indexCount < 2) {
// A pointer that stops at the whole per-vertex struct can still
// reach PointSize through a second chain; following that split
// is not worth the shapes it would have to prove absent.
bool touchesPointSize = false;
defUse->ForEachUser(user, [&](Instruction* chainUser) {
if ((chainUser->opcode() == spv::Op::OpAccessChain ||
chainUser->opcode() == spv::Op::OpInBoundsAccessChain) &&
chainUser->NumInOperands() >= 2) {
uint32_t member = 0;
if (PlainConstantValue(ctx, chainUser->GetSingleWordInOperand(1),
member) &&
member == blockVar.memberIndex) {
touchesPointSize = true;
}
} else if (chainUser->opcode() == spv::Op::OpLoad ||
chainUser->opcode() == spv::Op::OpStore ||
chainUser->opcode() == spv::Op::OpCopyMemory) {
touchesPointSize = true; // whole-struct copy
}
});
if (touchesPointSize) {
declined = true;
reason = "a split access chain or whole-struct copy reaching PointSize";
}
return;
}
uint32_t member = 0;
if (!PlainConstantValue(ctx, user->GetSingleWordInOperand(2), member)) {
declined = true;
reason = "a non-constant gl_PerVertex member index";
return;
}
if (member != blockVar.memberIndex) return; // another member
if (indexCount != 2) {
declined = true;
reason = "an access chain that continues past the PointSize member";
return;
}
arrayedRedirects.push_back(
{user, blockVar.storage == spv::StorageClass::Input});
return;
}
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpCopyMemory:
declined = true;
reason = "a whole-aggregate load/store/copy of the gl_PerVertex interface";
return;
default:
declined = true;
reason = std::format("SPIR-V opcode {} reaching the gl_PerVertex interface",
static_cast<uint32_t>(user->opcode()));
return;
}
});
if (declined) return decline(Move(reason));
}
// Standalone variables: swapping the decoration is only sound for the float /
// float-array shapes the built-in is allowed to have; mixing forms in one
// direction never comes out of glslang and declines.
struct StandaloneSwap {
Instruction* var = nullptr;
Instruction* builtInDecoration = nullptr;
bool input = false;
};
std::vector<StandaloneSwap> standaloneSwaps;
for (SizeT i = 0; i < standaloneVars.size(); ++i) {
Instruction* var = standaloneVars[i];
const auto storage = static_cast<spv::StorageClass>(var->GetSingleWordInOperand(0));
if (storage != spv::StorageClass::Input && storage != spv::StorageClass::Output) {
continue;
}
const bool input = storage == spv::StorageClass::Input;
uint32_t pointeeId = VariablePointeeType(ctx, var);
Instruction* pointee = defUse->GetDef(pointeeId);
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
pointee = defUse->GetDef(pointee->GetSingleWordInOperand(0));
}
if (pointee == nullptr || pointee->opcode() != spv::Op::OpTypeFloat) {
return decline("a standalone PointSize variable of an unexpected type");
}
if (input && arrayedInput != nullptr) {
return decline("PointSize declared both as a block member and standalone (input)");
}
if (!input && (arrayedOutput != nullptr || !scalarOutputChains.empty())) {
return decline("PointSize declared both as a block member and standalone (output)");
}
standaloneSwaps.push_back({var, standaloneBuiltInDecorations[i], input});
}
bool needsInputCarrier = false;
bool needsOutputCarrier = m_options.forceOutputCarrier;
for (const ArrayedRedirect& redirect : arrayedRedirects) {
(redirect.input ? needsInputCarrier : needsOutputCarrier) = true;
// Only a control stage has an ARRAYED output block; anywhere else this
// shape would hand a scalar carrier an extra index.
if (!redirect.input && !isTessControl) {
return decline("an arrayed PointSize output outside a control stage");
}
}
if (!scalarOutputChains.empty()) {
needsOutputCarrier = true;
// And only evaluation/geometry stages have the non-arrayed output block.
if (isTessControl) {
return decline("a non-arrayed PointSize output in a control stage");
}
}
bool standaloneInputSwapped = false;
bool standaloneOutputSwapped = false;
for (const StandaloneSwap& swap : standaloneSwaps) {
(swap.input ? standaloneInputSwapped : standaloneOutputSwapped) = true;
}
if (needsInputCarrier && arrayedInput == nullptr) {
return decline("a PointSize read with no arrayed input block to size the carrier by");
}
if (needsInputCarrier && m_options.inputCarrierName.empty()) {
return decline("a PointSize read with no input carrier name to bind it to");
}
if ((needsOutputCarrier && !standaloneOutputSwapped) &&
m_options.outputCarrierName.empty()) {
return decline("a PointSize write with no output carrier name to bind it to");
}
// The TCS output carrier is arrayed per vertex; its length comes from gl_out,
// or - for a forced carrier in a control stage that never declared gl_out -
// from the OutputVertices execution mode.
uint32_t outputArrayLengthConstId = 0;
uint32_t outputArrayLengthValue = 0;
if (isTessControl && needsOutputCarrier && !standaloneOutputSwapped) {
if (arrayedOutput != nullptr) {
outputArrayLengthConstId = arrayedOutput->arrayLengthConstId;
outputArrayLengthValue = arrayedOutput->arrayLengthValue;
} else {
for (Instruction& mode : ctx->module()->execution_modes()) {
if (mode.NumInOperands() >= 3 &&
static_cast<spv::ExecutionMode>(mode.GetSingleWordInOperand(1)) ==
spv::ExecutionMode::OutputVertices) {
outputArrayLengthValue = mode.GetSingleWordInOperand(2);
break;
}
}
if (outputArrayLengthValue == 0) {
return decline("a control stage with neither gl_out nor OutputVertices");
}
outputArrayLengthConstId =
ctx->get_constant_mgr()->GetUIntConstId(outputArrayLengthValue);
}
}
const bool anyWork = needsInputCarrier || needsOutputCarrier ||
!standaloneSwaps.empty();
// Even with no access left to redirect (a dead read the sanitize chain already
// removed), a declared TessellationPointSize/GeometryPointSize capability must
// still be stripped - it alone makes the module unbuildable on the device.
std::vector<Instruction*> capabilitiesToStrip;
for (Instruction& capability : ctx->module()->capabilities()) {
if (capability.NumInOperands() < 1) continue;
const auto declared =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (declared == spv::Capability::TessellationPointSize ||
declared == spv::Capability::GeometryPointSize) {
capabilitiesToStrip.push_back(&capability);
}
}
if (!anyWork && capabilitiesToStrip.empty()) return Status::SuccessWithoutChange;
// Phase 2: MUTATION. Nothing below may decline.
const uint32_t floatTypeId = Float32Type(ctx);
uint32_t inputCarrierId = 0;
if (needsInputCarrier) {
const uint32_t arrayTypeId = ArrayOfFloat32Type(
ctx, arrayedInput->arrayLengthConstId, arrayedInput->arrayLengthValue);
inputCarrierId =
CreateCarrierVariable(ctx, entryPoint, arrayTypeId, spv::StorageClass::Input,
m_options.inputCarrierName, m_options.location);
}
uint32_t outputCarrierId = 0;
if (needsOutputCarrier && !standaloneOutputSwapped) {
uint32_t pointeeTypeId = floatTypeId;
if (isTessControl) {
pointeeTypeId =
ArrayOfFloat32Type(ctx, outputArrayLengthConstId, outputArrayLengthValue);
}
outputCarrierId =
CreateCarrierVariable(ctx, entryPoint, pointeeTypeId, spv::StorageClass::Output,
m_options.outputCarrierName, m_options.location);
}
// Scalar output chains first, while the def-use index still knows their uses.
for (Instruction* chain : scalarOutputChains) {
ctx->ReplaceAllUsesWith(chain->result_id(), outputCarrierId);
ctx->KillInst(chain);
}
// Arrayed chains are rewritten in place: same result id, same result type
// (pointer-to-float in the same storage class), one fewer index.
for (const ArrayedRedirect& redirect : arrayedRedirects) {
const uint32_t carrierId = redirect.input ? inputCarrierId : outputCarrierId;
const Operand vertexIndex = redirect.chain->GetInOperand(1);
redirect.chain->SetInOperands(Instruction::OperandList{
{SPV_OPERAND_TYPE_ID, {carrierId}}, vertexIndex});
}
// Standalone form: the variable becomes its own carrier.
for (const StandaloneSwap& swap : standaloneSwaps) {
ctx->KillInst(swap.builtInDecoration);
AddLocationDecoration(ctx, swap.var->result_id(), m_options.location);
std::vector<Instruction*> oldNames;
for (Instruction& debugInst : ctx->module()->debugs2()) {
if (debugInst.opcode() == spv::Op::OpName &&
debugInst.GetSingleWordInOperand(0) == swap.var->result_id()) {
oldNames.push_back(&debugInst);
}
}
for (Instruction* oldName : oldNames) ctx->KillInst(oldName);
AddNameFor(ctx, swap.var->result_id(),
swap.input ? m_options.inputCarrierName : m_options.outputCarrierName);
}
for (Instruction* capability : capabilitiesToStrip) {
ctx->KillInst(capability);
}
if (m_report != nullptr) {
m_report->createdInputCarrier = needsInputCarrier || standaloneInputSwapped;
}
ctx->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken DemotePointSizePass::CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report) {
return spvtools::Optimizer::PassToken(
MakeUnique<DemotePointSizePass>(Move(options), report));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,103 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Demotes gl_PointSize traffic in ONE tessellation or geometry module (or mirrors it
// out of a vertex module) into an ordinary inter-stage float varying, for devices
// that cannot host the built-in in those stages at all: no
// EXT/OES_tessellation_point_size / geometry_point_size on the ES driver, and
// shaderTessellationAndGeometryPointSize == VK_FALSE on the Vulkan one. Desktop GL
// treats the built-in as an ordinary per-vertex output, so the programs this rescues
// are legal GL - only the targets cannot spell them.
//
// What "demoted" means, precisely. In a tessellation/geometry stage every access
// chain that reaches the PointSize member of a gl_PerVertex block (gl_in[i]
// .gl_PointSize, gl_out[i].gl_PointSize, the non-arrayed output block's member) is
// redirected onto a plain float varying at the caller-chosen location - an arrayed
// Input for gl_in reads, an arrayed Output for TCS gl_out writes, a scalar Output
// for the TES/GS output - and the TessellationPointSize / GeometryPointSize
// capability is stripped. The gl_PerVertex STRUCT keeps its PointSize member,
// declared and decorated but no longer accessed: that is exactly the shape glslang
// produces for a program that never touches point size (it defers the capability to
// first use), which is the one shape proven to build on every extension-less driver
// this repairs. A standalone PointSize VARIABLE (never glslang's shape, but legal
// SPIR-V) is demoted in place: BuiltIn swapped for the Location, and the variable
// renamed to the carrier's name.
//
// A VERTEX module is never capability-limited (gl_PointSize is core there on both
// targets), so it keeps its built-in untouched and, when the next stage consumes the
// carrier, MIRRORS the built-in's value into the carrier at every return of the
// entry function - the VS->TCS half of the chain.
//
// The VALUE is what survives: gl_in[].gl_PointSize reads and transform-feedback
// captures see exactly what the upstream stage wrote. The RASTERIZED point size is
// what does not - with the built-in unhosted, both targets rasterize such pipelines
// at the default size 1.0 (Vulkan: the shaderTessellationAndGeometryPointSize
// feature description; ES: PointSizeRange default) - so rasterization-verified
// point_rendering tests keep failing honestly and nothing may be gated on them.
//
// Anything the pass cannot express - a whole gl_PerVertex struct load/store/copy, a
// pointer that escapes into an opcode it cannot follow, an access-chain split across
// two chains - DECLINES the module byte-identically, reported through the report
// struct, so the caller keeps the existing honest refusal paths instead of shipping
// a half-demoted program.
//
// One module per run; the PROGRAM-wide contract (every stage demoted or none, one
// shared location, matching carrier names across each boundary) is owned by
// ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram, the only caller.
struct DemotePointSizeOptions {
// The Location every carrier of this program uses; chosen by the caller past
// every location any stage of the program already consumes.
Uint32 location = 0;
// Name for the arrayed Input carrier (empty forbids creating one: a module that
// reads gl_in[].gl_PointSize with no name to give the carrier declines).
String inputCarrierName;
// Name for the Output carrier (scalar in VS/TES/GS, arrayed in TCS).
String outputCarrierName;
// Create the Output carrier even when this module never writes PointSize: the
// next stage reads it (Vulkan requires every consumed input to be produced,
// VUID-RuntimeSpirv-OpEntryPoint-08743), or a transform-feedback capture of
// gl_PointSize binds to it. The value is then whatever GL says an unwritten
// varying holds: undefined, exactly as the built-in would have been.
Bool forceOutputCarrier = false;
};
struct DemotePointSizeReport {
Bool declined = false;
String declineReason;
// The module reads incoming PointSize, so an Input carrier now exists - which
// obliges the PREVIOUS stage to produce the matching Output carrier. The driver
// walks the stages back-to-front off exactly this bit.
Bool createdInputCarrier = false;
};
class DemotePointSizePass : public spvtools::opt::Pass {
public:
DemotePointSizePass(DemotePointSizeOptions options, DemotePointSizeReport* report)
: m_options(Move(options)), m_report(report) {}
const char* name() const override { return "mobilegl-demote-point-size"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateDemotePointSizePass(
DemotePointSizeOptions options, DemotePointSizeReport* report);
private:
DemotePointSizeOptions m_options;
DemotePointSizeReport* m_report;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -34,7 +34,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// gated, so one L1 key shape can describe two materially different module sets (real
// doubles vs demoted-and-flattened) and a blob written under 4 says nothing about
// which one it holds.
constexpr Uint32 kKeyLayoutVersion = 5u;
// 6: L1 gained the two point-size demotion bits (demoteTessellationPointSize /
// demoteGeometryPointSize). Phase B now rewrites the cached modules on a device
// that cannot host gl_PointSize in tessellation/geometry stages, so a blob
// written under 5 says nothing about whether its modules were demoted.
constexpr Uint32 kKeyLayoutVersion = 6u;
// The repo's existing cache epoch (MG_Config::CacheVersion, the seed
// ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory
@@ -128,6 +132,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.Value(inputs.shaderCompileFlags);
builder.Value(static_cast<Uint8>(inputs.enableSpirvValidation));
builder.Value(static_cast<Uint8>(inputs.nativeFloat64));
builder.Value(static_cast<Uint8>(inputs.demoteTessellationPointSize));
builder.Value(static_cast<Uint8>(inputs.demoteGeometryPointSize));
builder.Value(static_cast<Uint64>(inputs.stages.size()));
for (const auto& stage : inputs.stages) {
builder.Value(static_cast<Uint32>(stage.type));
@@ -411,9 +411,17 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
const UnorderedMap<String, Uint>* explicitFragmentOutIndices = nullptr;
Uint32 shaderCompileFlags = 0;
Bool enableSpirvValidation = false;
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. The
// one backend capability bit in this key; see the note above for why it has to be.
// CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. See
// the note above for why it has to be here.
Bool nativeFloat64 = false;
// CompileEnv::DemotesTessellationPointSize() / DemotesGeometryPointSize() - the
// second and third capability bits under the same rule as nativeFloat64: each ARMS
// a phase-B rewrite of the cached modules themselves
// (ShaderCompiler::DemoteTessellationGeometryPointSizeForProgram), so the same GLSL
// produces materially different module sets under the two answers - built-in
// point size kept, or carried as an ordinary varying with the capability stripped.
Bool demoteTessellationPointSize = false;
Bool demoteGeometryPointSize = false;
// ---- inputs that only matter because the PAYLOAD now carries the reflection ----
// When the payload was SPIR-V alone these were provably irrelevant: transform
// feedback is resolved by READING the linked intermediates and never writes an XFB