diff --git a/CMakeLists.txt b/CMakeLists.txt index 93fa2f2f..eb574dc6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 51d629b5..8e9962f0 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -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 diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 55e99d93..47809384 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -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"); diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index 223bedee..d528d820 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -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; diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index 1d2440a3..c9905508 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -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; diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 836f6f8d..f18f0f50 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -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 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; } diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 2ed4d34e..e8ce9d1c 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -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 diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index b001bbcb..a460d456 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -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 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> moduleSpirvs(spirv.size()); const Bool enableSpirvValidation = program.GetSpirvValidationEnabled(); // Unconditional now: the two ValidateTransformedSpirv calls below run in every build, diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 28100a2c..ccc68687 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -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); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 5424d6d6..506f0fac 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -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 ---- diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp index 03761473..74780c8c 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -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, diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h index d3bc4a3a..6d25f99b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h @@ -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 diff --git a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp index f0e688d4..e2d9a364 100644 --- a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp +++ b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp @@ -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 diff --git a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h index 74364939..33907086 100644 --- a/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h +++ b/MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.h @@ -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 - diff --git a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h index 98804e7a..ad7fa858 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h +++ b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h @@ -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 { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 05cb18e9..17a13cd5 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -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(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& spirv) { + PointSizeModuleProbe probe; + if (spirv.empty()) { + probe.parsed = true; // an absent stage constrains nothing + return probe; + } + std::unique_ptr 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(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(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 patchDecorated; + for (spvtools::opt::Instruction& annotation : context->annotations()) { + if (annotation.opcode() == spv::Op::OpDecorate && annotation.NumInOperands() >= 2 && + static_cast(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(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(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(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>& modules, const Vector& 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(i); break; + case GL_TESS_CONTROL_SHADER: stageIndex[1] = static_cast(i); break; + case GL_TESS_EVALUATION_SHADER: stageIndex[2] = static_cast(i); break; + case GL_GEOMETRY_SHADER: stageIndex[3] = static_cast(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> 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& spirv) { if (spirv.empty()) { // Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index ba7b87a7..d921022f 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -562,6 +562,53 @@ namespace MobileGL { // the module parse costs nothing on a device that has it. static Bool ModuleDeclaresTessellationOrGeometryPointSize(const Vector& 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>& modules, const Vector& 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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp new file mode 100644 index 00000000..e45294ca --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.cpp @@ -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 +#include + +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(ep.GetSingleWordInOperand(0)); + } + return spv::ExecutionModel::Max; + } + + Instruction* EntryPoint(IRContext* ctx) { + for (Instruction& ep : ctx->module()->entry_points()) { + return &ep; + } + return nullptr; + } + + // OpTypePointer + 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(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 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(ctx, spv::Op::OpName, 0, 0, operands)); + } + + void AddLocationDecoration(IRContext* ctx, uint32_t id, uint32_t location) { + ctx->AddAnnotationInst(spvtools::MakeUnique( + ctx, spv::Op::OpDecorate, 0, 0, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {id}}, + {SPV_OPERAND_TYPE_DECORATION, + {static_cast(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( + ctx, spv::Op::OpVariable, ptrTypeId, varId, + std::initializer_list{ + {SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast(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 memberSites; + // Standalone form: a variable decorated BuiltIn PointSize directly. + std::vector standaloneVars; + std::vector standaloneBuiltInDecorations; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpMemberDecorate && ann.NumInOperands() >= 4 && + static_cast(ann.GetSingleWordInOperand(2)) == + spv::Decoration::BuiltIn && + static_cast(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(ann.GetSingleWordInOperand(1)) == + spv::Decoration::BuiltIn && + static_cast(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 blockVars; + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable) continue; + const auto storage = static_cast(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(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(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( + ctx, spv::Op::OpAccessChain, ptrOutputFloatId, chainId, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {blockVar->result_id()}}, + {SPV_OPERAND_TYPE_ID, {memberConstId}}})); + valueId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, floatTypeId, valueId, + std::initializer_list{{SPV_OPERAND_TYPE_ID, {chainId}}})); + } else if (standaloneOut != nullptr) { + valueId = ctx->TakeNextId(); + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpLoad, floatTypeId, valueId, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {standaloneOut->result_id()}}})); + } else { + valueId = defaultOneId; + } + inst->InsertBefore(spvtools::MakeUnique( + ctx, spv::Op::OpStore, 0, 0, + std::initializer_list{{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 arrayedRedirects; // gl_in[i].ps / gl_out[i].ps + std::vector 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(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 standaloneSwaps; + for (SizeT i = 0; i < standaloneVars.size(); ++i) { + Instruction* var = standaloneVars[i]; + const auto storage = static_cast(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(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 capabilitiesToStrip; + for (Instruction& capability : ctx->module()->capabilities()) { + if (capability.NumInOperands() < 1) continue; + const auto declared = + static_cast(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 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(Move(options), report)); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.h new file mode 100644 index 00000000..6d22bef4 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemotePointSizePass.h @@ -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 + +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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 72c3bf97..bc7af4a9 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -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(inputs.enableSpirvValidation)); builder.Value(static_cast(inputs.nativeFloat64)); + builder.Value(static_cast(inputs.demoteTessellationPointSize)); + builder.Value(static_cast(inputs.demoteGeometryPointSize)); builder.Value(static_cast(inputs.stages.size())); for (const auto& stage : inputs.stages) { builder.Value(static_cast(stage.type)); diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index 14c42b60..dda3ea67 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -411,9 +411,17 @@ namespace MobileGL::MG_Util::ShaderTranspiler { const UnorderedMap* 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