From 532836c0584b5cb4bba1260c8b9492ccdc250ecd Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 06:11:50 -0400 Subject: [PATCH 1/5] [Feat, Test] (MG_Util): demote every 64-bit float in a shader to 32 bits, with the block layout re-derived --- CMakeLists.txt | 1 + .../MG_Test/ShaderTranspiler/CMakeLists.txt | 1 + .../ShaderTranspiler/DemoteFloat64Test.cpp | 486 ++++++++++++++++ .../ShaderTranspiler/ShaderCompiler.cpp | 43 ++ .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 12 + .../SpirvPasses/DemoteFloat64Pass.cpp | 547 ++++++++++++++++++ .../SpirvPasses/DemoteFloat64Pass.h | 84 +++ 7 files changed, 1174 insertions(+) create mode 100644 MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b629c204..ff4cd6e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -276,6 +276,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index 714f8d51..18da1c3b 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.14) add_executable( SpirvPassTest SpirvPassTest.cpp + DemoteFloat64Test.cpp ) target_include_directories(SpirvPassTest PRIVATE diff --git a/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp b/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp new file mode 100644 index 00000000..67dc8256 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp @@ -0,0 +1,486 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.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 + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include + +#include + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; + +namespace { + // The test-side reference walker, deliberately independent of the production code: a bug in + // the pass must not be able to hide behind the same helper. Counts OpTypeFloat declarations of + // a given width and collects the Offset literal of every OpMemberDecorate, in module order. + constexpr Uint32 kSpirvHeaderWordCount = 5; + constexpr Uint32 kOpTypeFloat = 22; + constexpr Uint32 kOpName = 5; + constexpr Uint32 kOpMemberDecorate = 72; + constexpr Uint32 kOpFConvert = 115; + constexpr Uint32 kOpCapability = 17; + constexpr Uint32 kDecorationOffset = 35; + constexpr Uint32 kCapabilityFloat64 = 10; + + template + void ForEachInstruction(const Vector& spirv, Visitor&& visit) { + for (SizeT i = kSpirvHeaderWordCount; i < spirv.size();) { + const Uint32 wordCount = spirv[i] >> 16; + const Uint32 opcode = spirv[i] & 0xFFFFu; + if (wordCount == 0 || i + wordCount > spirv.size()) break; + visit(opcode, &spirv[i], wordCount); + i += wordCount; + } + } + + Uint32 CountFloatTypesOfWidth(const Vector& spirv, Uint32 width) { + Uint32 count = 0; + ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) { + if (opcode == kOpTypeFloat && wordCount >= 3 && words[2] == width) ++count; + }); + return count; + } + + Uint32 CountFConverts(const Vector& spirv) { + Uint32 count = 0; + ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32*, Uint32) { + if (opcode == kOpFConvert) ++count; + }); + return count; + } + + Bool DeclaresFloat64Capability(const Vector& spirv) { + Bool found = false; + ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) { + if (opcode == kOpCapability && wordCount >= 2 && words[1] == kCapabilityFloat64) found = true; + }); + return found; + } + + // Byte offset of every member of the struct named `blockName`, in member order. + Vector CollectOffsetsOf(const Vector& spirv, const String& blockName) { + Uint32 structId = 0; + ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) { + if (opcode != kOpName || wordCount < 3 || structId != 0) return; + const char* text = reinterpret_cast(&words[2]); + const SizeT maxBytes = (wordCount - 2) * sizeof(Uint32); + if (std::strncmp(text, blockName.c_str(), maxBytes) == 0) structId = words[1]; + }); + if (structId == 0) return {}; + + std::map offsetByMember; + ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) { + if (opcode == kOpMemberDecorate && wordCount >= 5 && words[1] == structId && + words[3] == kDecorationOffset) { + offsetByMember[words[2]] = words[4]; + } + }); + + Vector offsets; + for (const auto& [member, offset] : offsetByMember) offsets.push_back(offset); + return offsets; + } + + // Everything the production pipeline does to a source before the pass sees it. + Vector CompileToSpirv(GLenum stage, const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult || binaryResult->empty()) return {}; + return binaryResult->front(); + } + + String Disassemble(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + tools.Disassemble(spirv, &text); + return text; + } + + // A vertex shader that exercises every shape the pass has to handle at once: a block with + // double / dvec2 / dvec3 / dvec4 / dmat4 members between two floats (so a shifted offset would + // be visible), a default-block double uniform, a 64-bit vertex input, a double-typed array, an + // implicit float->double conversion and an explicit double->float one. + const char* kWideVertexSource = R"(#version 460 core +layout(std140, binding = 0) uniform Blk { + float a; + double d; + dvec2 v2; + dvec3 v3; + dvec4 v4; + dmat4 m4; + double arr[3]; + float z; +}; +layout(location = 0) uniform double uScale; +layout(location = 0) in dvec3 inPos; +layout(location = 1) in vec3 inNormal; +layout(location = 0) out float vOut; +void main() { + double s = d * uScale + a; + dvec3 p = inPos * v3 + v2.xyx + v4.xyz + dvec3(m4[0].xyz); + s += p.x + p.y + p.z + arr[0] + arr[1] + arr[2] + z + 0.5lf; + vOut = float(s) + inNormal.x; + gl_Position = vec4(float(s)); +} +)"; +} // namespace + +class DemoteFloat64Test : public ::testing::Test { +protected: + void SetUp() override { + MobileGL::Initialize(); + ShaderCompiler::SetSpirvValidationEnabled(true); + m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount(); + } + + void TearDown() override { + // The wrapper validates its OUTPUT on every run, so this covers every demotion the test + // performed without any of them having to say so. + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart) + << "the demoted module did not survive spirv-val"; + } + + Uint64 m_validationFailuresAtStart = 0; +}; + +TEST_F(DemoteFloat64Test, DemotesEveryWidthAndDropsTheCapability) { + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource); + ASSERT_FALSE(input.empty()); + ASSERT_EQ(CountFloatTypesOfWidth(input, 64), 1u) << Disassemble(input); + ASSERT_TRUE(DeclaresFloat64Capability(input)); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output); + // And exactly one 32-bit float type survives: the merge has to happen, or spirv-val rejects + // the second declaration. + EXPECT_EQ(CountFloatTypesOfWidth(output, 32), 1u) << Disassemble(output); + EXPECT_FALSE(DeclaresFloat64Capability(output)); +} + +TEST_F(DemoteFloat64Test, RederivesTheStd140LayoutOfADemotedUniformBlock) { + const String source = R"(#version 460 core +layout(std140, binding = 0) uniform Blk { + float a; + double d; + dvec2 v2; + dvec3 v3; + dvec4 v4; + dmat4 m4; + double arr[3]; + float z; +}; +layout(location = 0) out float vOut; +void main() { + vOut = float(d + v2.x + v3.y + v4.z + m4[2].w + arr[1] + a + z); + gl_Position = vec4(vOut); +} +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + // What glslang laid out for the 64-bit members, which is what an application computing + // std140 by hand would also get. + EXPECT_EQ(CollectOffsetsOf(input, "Blk"), (Vector{0, 8, 16, 32, 64, 96, 224, 272})) + << Disassemble(input); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + // std140 for the demoted members: float at 4, vec2 at 8, vec3 at 16 (aligned like a vec4), + // vec4 at 32, mat4 at 48 with a 16-byte column stride, the array at 112 with the std140 + // 16-byte element stride, and the trailing float at 160. This is what SPIRV-Cross has to be + // able to re-derive for GLSL ES, which has no member layout(offset=) to fall back on. + EXPECT_EQ(CollectOffsetsOf(output, "Blk"), (Vector{0, 4, 8, 16, 32, 48, 112, 160})) + << Disassemble(output); + const String text = Disassemble(output); + EXPECT_NE(text.find("MatrixStride 16"), String::npos) << text; + EXPECT_NE(text.find("ArrayStride 16"), String::npos) << text; +} + +TEST_F(DemoteFloat64Test, RederivesTheStd430LayoutOfADemotedStorageBlock) { + const String source = R"(#version 460 core +layout(std430, binding = 0) buffer Ssbo { + double head; + dvec4 wide; + double tail[4]; +}; +layout(location = 0) out float vOut; +void main() { + vOut = float(head + wide.w + tail[3]); + gl_Position = vec4(vOut); +} +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + EXPECT_EQ(CollectOffsetsOf(input, "Ssbo"), (Vector{0, 32, 64})) << Disassemble(input); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + // std430, so the array packs at its element size rather than being rounded to 16: float at 0, + // vec4 at 16, float[4] at 32 with a 4-byte stride. A storage block must NOT come out std140, + // which is the whole reason the packing is chosen per storage class. + EXPECT_EQ(CollectOffsetsOf(output, "Ssbo"), (Vector{0, 16, 32})) << Disassemble(output); + EXPECT_NE(Disassemble(output).find("ArrayStride 4"), String::npos) << Disassemble(output); +} + +TEST_F(DemoteFloat64Test, LeavesTheLayoutOfABlockWithoutDoublesAlone) { + const String source = R"(#version 460 core +layout(std140, binding = 0) uniform Blk { + float a; + vec3 v3; + mat4 m4; +}; +layout(std140, binding = 1) uniform Wide { + float w; + double d; +}; +layout(location = 0) out float vOut; +void main() { + vOut = float(a + v3.y + m4[1].z + float(d) + w); + gl_Position = vec4(vOut); +} +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + const Vector before = CollectOffsetsOf(input, "Blk"); + ASSERT_FALSE(before.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + // Only the block that actually narrowed is re-laid-out. Touching the other one would be + // churn at best, and a disagreement with glslang's own layout at worst. + EXPECT_EQ(CollectOffsetsOf(output, "Blk"), before) << Disassemble(output); + EXPECT_EQ(CollectOffsetsOf(output, "Wide"), (Vector{0, 4})) << Disassemble(output); +} + +TEST_F(DemoteFloat64Test, FoldsTheConversionsThatBecameIdentities) { + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource); + ASSERT_FALSE(input.empty()); + ASSERT_GT(CountFConverts(input), 0u) << "the fixture no longer converts between the two widths"; + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + // SPIR-V requires the two component widths of an OpFConvert to differ, so every one of them + // has to be gone: both sides are 32 bits now. + EXPECT_EQ(CountFConverts(output), 0u) << Disassemble(output); +} + +TEST_F(DemoteFloat64Test, NarrowsDoubleConstantsToTheirFloatValue) { + const String source = R"(#version 460 core +layout(location = 0) out float outValue; +void main() { + double d = 0.5lf; + outValue = float(d * 0.25lf); + gl_Position = vec4(0.0); +} +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + + // A 64-bit literal is two words wide and a 32-bit one is a single word, so a constant left + // unconverted is not merely imprecise - it is an unparseable instruction. Disassembling both + // values proves the re-encode produced the right number, not just the right width. + const String text = Disassemble(output); + EXPECT_NE(text.find("OpConstant %float 0.5"), String::npos) << text; + EXPECT_NE(text.find("OpConstant %float 0.25"), String::npos) << text; +} + +TEST_F(DemoteFloat64Test, LeavesAModuleWithoutDoublesByteIdentical) { + const String source = R"(#version 460 core +layout(location = 0) in vec4 inPos; +void main() { gl_Position = inPos; } +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + // The pass reports SuccessWithoutChange here, and SPIRV-Tools asserts (in assert-enabled + // builds) that such a run round-trips byte-identically. + EXPECT_EQ(output, input); +} + +TEST_F(DemoteFloat64Test, DeclinesAModuleThatBitcastsAcrossTheWidthBoundary) { + // packDouble2x32 is defined only for a 64-bit result: there is no 32-bit answer to give, and + // narrowing one side of the surrounding OpBitcast alone produces a module spirv-val rejects. + // The contract is that such a module comes back untouched rather than broken. + const String source = R"(#version 460 core +#extension GL_ARB_gpu_shader_fp64 : require +layout(location = 0) uniform uvec2 uPacked; +layout(location = 0) out float outValue; +void main() { + double d = packDouble2x32(uPacked); + outValue = float(d); + gl_Position = vec4(0.0); +} +)"; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, source); + ASSERT_FALSE(input.empty()); + ASSERT_EQ(CountFloatTypesOfWidth(input, 64), 1u) << Disassemble(input); + + Vector output; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output)); + EXPECT_EQ(output, input) << Disassemble(output); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(output)); +} + +TEST_F(DemoteFloat64Test, ModuleDeclaresFloat64AnswersBothWays) { + const Vector wide = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource); + ASSERT_FALSE(wide.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(wide)); + + Vector demoted; + ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(wide, demoted)); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(demoted)); + + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64({})); +} + +TEST_F(DemoteFloat64Test, TheSharedChainDemotesToo) { + // Production never calls the pass on its own: it reaches it through the one chain every + // module goes through at link, on both backends. + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output)); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(output)) << Disassemble(output); +} + +// The payoff on the Espryt path: SPIRV-Cross throws "FP64 not supported in ES profile" for every +// one of these before demotion, so the program simply could not be transpiled at all. +class DemoteFloat64EsslTest : public DemoteFloat64Test, public ::testing::WithParamInterface {}; + +INSTANTIATE_TEST_SUITE_P( + Shapes, DemoteFloat64EsslTest, + ::testing::Values( + // A double that never reaches an interface: locals and literals only. + R"(#version 460 core +layout(location = 0) out float vOut; +void main() { + double s = 0.5lf; + for (int i = 0; i < 3; ++i) s = s * 1.5lf + 0.25lf; + vOut = float(s); + gl_Position = vec4(float(s)); +} +)", + // A default-block double uniform: the glUniform*d path, and the block MobileGL lays out + // itself. + R"(#version 460 core +layout(location = 0) uniform double uScale; +layout(location = 1) uniform dvec3 uOffset; +layout(location = 2) uniform dmat4 uTransform; +layout(location = 0) out float vOut; +void main() { + dvec3 p = uOffset * uScale + dvec3(uTransform[1].xyz); + vOut = float(p.x + p.y + p.z); + gl_Position = vec4(float(p.x)); +} +)", + // An application-declared std140 block whose members are 64-bit. + R"(#version 460 core +layout(std140, binding = 0) uniform Blk { + float a; + double d; + dvec2 v2; + dvec3 v3; + dvec4 v4; + dmat4 m4; + double arr[3]; + float z; +}; +layout(location = 0) out float vOut; +void main() { + double s = d + v2.x + v3.y + v4.z + m4[2].w + arr[1] + a + z; + vOut = float(s); + gl_Position = vec4(float(s)); +} +)", + // A 64-bit vertex input, which is what glVertexAttribLFormat feeds. + R"(#version 460 core +layout(location = 0) in dvec3 inPos; +layout(location = 2) in double inWeight; +layout(location = 0) out float vOut; +void main() { + vOut = float(inPos.x + inPos.y + inPos.z + inWeight); + gl_Position = vec4(vOut); +} +)", + // An std430 storage block, whose double members pack differently again. + R"(#version 460 core +layout(std430, binding = 0) buffer Ssbo { + double head; + dvec4 wide; + double tail[4]; +}; +layout(location = 0) out float vOut; +void main() { + double s = head + wide.w + tail[3]; + vOut = float(s); + gl_Position = vec4(float(s)); +} +)")); + +TEST_P(DemoteFloat64EsslTest, TheDemotedModuleCanBeEmittedAsEssl) { + using namespace MG_Util::ShaderTranspiler; + const Vector input = CompileToSpirv(GL_VERTEX_SHADER, GetParam()); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output)); + + SpvcSession session(output, SessionUsageBit::Transpile); + spvc_compiler_options options; + ASSERT_EQ(session.CreateOptions(&options), SPVC_SUCCESS); + spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE); + spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE); + ASSERT_EQ(session.SetOptions(options), SPVC_SUCCESS); + + auto essl = ShaderCompiler::DecompileShader(session); + ASSERT_TRUE(essl) << essl.error().log; + EXPECT_NE(essl->find("#version 320 es"), String::npos) << *essl; + // ESSL has no 64-bit float spelling at all, so any of these in the output is SPIRV-Cross + // having emitted something no ES driver will compile. + EXPECT_EQ(essl->find("double"), String::npos) << *essl; + EXPECT_EQ(essl->find("dvec"), String::npos) << *essl; + EXPECT_EQ(essl->find("dmat"), String::npos) << *essl; +} + +TEST_F(DemoteFloat64Test, RejectsGarbageInput) { + const Vector notSpirv{0xdeadbeefu, 0u, 0u, 0u, 0u}; + Vector output; + EXPECT_FALSE(ShaderCompiler::DemoteFloat64ToFloat32(notSpirv, output)); +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 8244b0b2..69349a38 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -18,6 +18,7 @@ #include "SpirvPasses/RenameBuiltinShadowingFunctionsPass.h" #include "SpirvPasses/DecomposeWorkgroupVec3Pass.h" #include "SpirvPasses/DecoratePositionInvariantPass.h" +#include "SpirvPasses/DemoteFloat64Pass.h" #include "SpirvPasses/LowerDrawParametersPass.h" #include "SpirvPasses/PackDoubleVertexInputsPass.h" #include "SpirvPasses/SplitArrayVertexInputsPass.h" @@ -578,6 +579,37 @@ namespace MobileGL { return false; } + Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector& spirv) { + if (spirv.empty()) { + // Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced + // no SPIR-V is not a verdict about 64-bit floats, and parsing it would push a + // spurious diagnostic through the message consumer. + return false; + } + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresFloat64"), spirv.data(), + spirv.size()); + if (!context) { + return false; + } + for (const spvtools::opt::Instruction& type : context->types_values()) { + if (type.opcode() == spv::Op::OpTypeFloat && type.NumInOperands() >= 1 && + type.GetSingleWordInOperand(0) == 64) { + return true; + } + } + return false; + } + + bool ShaderCompiler::DemoteFloat64ToFloat32(const Vector& inputBinary, + Vector& outputBinary) { + using namespace spvtools; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass()); + + return RunOptimizerChecked("DemoteFloat64ToFloat32", optimizer, inputBinary, outputBinary); + } + bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector& inputBinary, Vector& outputBinary) { using namespace spvtools; @@ -616,6 +648,17 @@ namespace MobileGL { RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass()); optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass()); optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass()); + // No mobile GPU has 64-bit floats: Adreno and Mali both report shaderFloat64 == + // VK_FALSE, and ESSL has no fp64 type for SPIRV-Cross to emit. Demoting here - in + // the one chain every module goes through, on both backends, at link - is what + // makes `double` compile at all, and makes it behave the SAME everywhere, which + // matters because the GL frontend's uniform storage cannot be per-backend: the + // glUniform*d shadow narrows to float unconditionally to match this. Runs last so + // no earlier pass ever has to reason about a width it will not see in the output; + // in particular it runs before the backends' PackDoubleVertexInputsPass, whose + // OpBitcast this one would otherwise decline on. Costs one types_values() walk on + // the overwhelming majority of modules, which declare no 64-bit float at all. + optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass()); return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary, outputBinary); diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 41ff3461..5340dfd2 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -100,6 +100,12 @@ namespace MobileGL { // reinterpretation paths (for example, R32F storage accessed as r32ui). static bool UseUnformattedFloatStorageImagesForVulkan( const Vector& inputBinary, Vector& outputBinary); + // Rewrites every 64-bit float in the module to a 32-bit one, preserving every + // block offset and stride exactly (see DemoteFloat64Pass). Already part of + // SanitizeAndOptimizeBinary, which is where production reaches it; exposed + // separately so a test can drive the demotion on its own. + static bool DemoteFloat64ToFloat32(const Vector& inputBinary, + Vector& outputBinary); static Result DecompileShader(SpvcSession& session); // Parses one trivial shader in each configuration the production path can @@ -159,6 +165,12 @@ namespace MobileGL { // check exists so that failure can be reported as the missing capability it is, // naming the shader, rather than as a driver info log nobody sees. static Bool ModuleDeclaresBufferTextureSampler(const Vector& spirv); + + // 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 + // what the backends report: no mobile driver can build such a module. + static Bool ModuleDeclaresFloat64(const Vector& spirv); }; } // namespace ShaderTranspiler } // namespace MG_Util diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp new file mode 100644 index 00000000..a2b5292b --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp @@ -0,0 +1,547 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.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 "DemoteFloat64Pass.h" + +#include "spirv.hpp" +#include "source/latest_version_glsl_std_450_header.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/util/make_unique.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::IRContext; + using spvtools::opt::Instruction; + using spvtools::opt::Operand; + + constexpr Uint32 kFloat64Width = 64; + constexpr Uint32 kFloat32Width = 32; + + // OpTypeFloat . SPIR-V 1.6 added an optional FP Encoding operand after + // it; the width stays operand 0 either way, and an encoded (non-IEEE) float is not + // something glslang can emit for `double`, so it is left alone by the width guard. + Bool IsFloatTypeOfWidth(const Instruction& type, Uint32 width) { + return type.opcode() == spv::Op::OpTypeFloat && type.NumInOperands() >= 1 && + type.GetSingleWordInOperand(0) == width; + } + + // Exactly the types spirv-val forbids a second declaration of. Aggregates - arrays + // and structs - are excluded on purpose: the spec permits duplicates of those, and + // merging them would take one of the two OpNames and one of the two ArrayStride / + // Offset decoration sets with it. + Bool IsDuplicableType(spv::Op opcode) { + switch (opcode) { + case spv::Op::OpTypeFloat: + case spv::Op::OpTypeVector: + case spv::Op::OpTypeMatrix: + case spv::Op::OpTypePointer: + case spv::Op::OpTypeFunction: return true; + default: return false; + } + } + + Uint32 RoundUp(Uint32 value, Uint32 alignment) { + if (alignment == 0) return value; + return (value + alignment - 1) / alignment * alignment; + } + + // Re-derives the std140 / std430 layout of a block whose members have just become + // 32 bits wide, and writes it back as Offset / ArrayStride / MatrixStride. + // + // Why the layout is recomputed rather than preserved. Preserving it - leaving each + // member at the byte offset glslang picked for the 64-bit type and letting the + // freed 4 bytes become padding - keeps the application's byte layout intact and is + // the obvious first choice, but it does not survive contact with the Espryt path: + // SPIRV-Cross has to print an ESSL block, GLSL ES has no member `layout(offset=)` + // (no ARB_enhanced_layouts), so it refuses any block whose declared offsets are not + // exactly what std140 or std430 computes - "Buffer block cannot be expressed as any + // of std430, std140, scalar". Every shader with a double in a block would fail to + // transpile at all, which is the case this whole demotion exists to fix. Padding + // members back in cannot rescue it either: a dmat4 member carries MatrixStride 32 + // and std140 demands 16 for the demoted mat4, and no amount of padding BETWEEN + // members changes a stride INSIDE one. + // + // What recomputing costs: a block laid out for 64-bit members changes its + // driver-visible byte layout, so an application that hard-codes std140 offsets + // computed for doubles addresses the wrong bytes. Applications that query their + // offsets are unaffected, and MobileGL's own default-uniform block is unaffected by + // construction - the frontend builds its uniform routing by reflecting THIS module + // (ProgramSpirvTask::BuildGlobalUboRouting), so glUniform*d writes wherever the + // demoted shader reads. + class BlockRelayout { + public: + BlockRelayout(IRContext* irContext, Bool std140) + : m_irContext(irContext), m_std140(std140) {} + + // Size and alignment of `typeId`, applying every stride decoration it implies + // on the way down. Zero size means "not a type this layout knows how to + // describe"; the caller then leaves the block alone rather than guessing. + struct Extent { + Uint32 size = 0; + Uint32 alignment = 0; + }; + + Extent Measure(Uint32 typeId) { + const auto memo = m_extents.find(typeId); + if (memo != m_extents.end()) return memo->second; + + const Extent extent = MeasureUncached(typeId); + m_extents.emplace(typeId, extent); + return extent; + } + + private: + Extent MeasureUncached(Uint32 typeId) { + const Instruction* type = m_irContext->get_def_use_mgr()->GetDef(typeId); + if (type == nullptr) return {}; + + switch (type->opcode()) { + case spv::Op::OpTypeInt: + case spv::Op::OpTypeFloat: { + const Uint32 bytes = type->GetSingleWordInOperand(0) / 8; + return {bytes, bytes}; + } + case spv::Op::OpTypeBool: return {4, 4}; + case spv::Op::OpTypeVector: { + const Extent component = Measure(type->GetSingleWordInOperand(0)); + if (component.size == 0) return {}; + const Uint32 count = type->GetSingleWordInOperand(1); + // A three-component vector aligns like a four-component one. + return {component.size * count, + component.alignment * (count == 3 ? 4 : count)}; + } + case spv::Op::OpTypeMatrix: { + const Extent column = Measure(type->GetSingleWordInOperand(0)); + if (column.size == 0) return {}; + const Uint32 stride = MatrixOrArrayStride(column.alignment); + return {stride * type->GetSingleWordInOperand(1), stride}; + } + case spv::Op::OpTypeArray: + case spv::Op::OpTypeRuntimeArray: { + const Extent element = Measure(type->GetSingleWordInOperand(0)); + if (element.size == 0) return {}; + const Uint32 alignment = MatrixOrArrayStride(element.alignment); + const Uint32 stride = RoundUp(element.size, alignment); + SetTypeDecoration(typeId, spv::Decoration::ArrayStride, stride); + if (type->opcode() == spv::Op::OpTypeRuntimeArray) { + // An unsized array contributes its stride and nothing more; the + // block's size is whatever the application bound. + return {stride, alignment}; + } + return {stride * ArrayLength(type->GetSingleWordInOperand(1)), alignment}; + } + case spv::Op::OpTypeStruct: return MeasureStruct(*type); + default: return {}; + } + } + + Extent MeasureStruct(const Instruction& structType) { + Uint32 cursor = 0; + Uint32 alignment = m_std140 ? 16u : 1u; + for (Uint32 member = 0; member < structType.NumInOperands(); ++member) { + const Uint32 memberTypeId = structType.GetSingleWordInOperand(member); + const Extent extent = Measure(memberTypeId); + if (extent.size == 0) return {}; + + const Uint32 offset = RoundUp(cursor, extent.alignment); + SetMemberDecoration(structType.result_id(), member, spv::Decoration::Offset, offset); + // A matrix member carries the stride between its columns on the MEMBER, + // not on the type, so it has to be (re)stated here - including for a + // matrix reached through an array. + const Instruction* memberType = + m_irContext->get_def_use_mgr()->GetDef(PeelArrays(memberTypeId)); + if (memberType != nullptr && memberType->opcode() == spv::Op::OpTypeMatrix) { + const Extent column = Measure(memberType->GetSingleWordInOperand(0)); + SetMemberDecoration(structType.result_id(), member, + spv::Decoration::MatrixStride, + MatrixOrArrayStride(column.alignment)); + } + + cursor = offset + extent.size; + alignment = std::max(alignment, extent.alignment); + } + return {RoundUp(cursor, alignment), alignment}; + } + + // std140 rounds every array and matrix stride up to a four-component vector. + Uint32 MatrixOrArrayStride(Uint32 elementAlignment) const { + return m_std140 ? RoundUp(elementAlignment, 16) : elementAlignment; + } + + Uint32 PeelArrays(Uint32 typeId) const { + const Instruction* type = m_irContext->get_def_use_mgr()->GetDef(typeId); + while (type != nullptr && (type->opcode() == spv::Op::OpTypeArray || + type->opcode() == spv::Op::OpTypeRuntimeArray)) { + type = m_irContext->get_def_use_mgr()->GetDef(type->GetSingleWordInOperand(0)); + } + return type != nullptr ? type->result_id() : 0; + } + + Uint32 ArrayLength(Uint32 lengthConstantId) const { + const Instruction* length = m_irContext->get_def_use_mgr()->GetDef(lengthConstantId); + if (length == nullptr || length->opcode() != spv::Op::OpConstant || + length->NumInOperands() < 1) { + return 1; + } + return length->GetSingleWordInOperand(0); + } + + void SetTypeDecoration(Uint32 targetId, spv::Decoration decoration, Uint32 value) { + for (Instruction& annotation : m_irContext->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate) continue; + if (annotation.GetSingleWordInOperand(0) != targetId) continue; + if (static_cast(annotation.GetSingleWordInOperand(1)) != decoration) { + continue; + } + annotation.SetInOperand(2, {value}); + return; + } + } + + void SetMemberDecoration(Uint32 structId, Uint32 member, spv::Decoration decoration, + Uint32 value) { + for (Instruction& annotation : m_irContext->annotations()) { + if (annotation.opcode() != spv::Op::OpMemberDecorate) continue; + if (annotation.GetSingleWordInOperand(0) != structId) continue; + if (annotation.GetSingleWordInOperand(1) != member) continue; + if (static_cast(annotation.GetSingleWordInOperand(2)) != decoration) { + continue; + } + annotation.SetInOperand(3, {value}); + return; + } + } + + IRContext* m_irContext = nullptr; + Bool m_std140 = true; + std::unordered_map m_extents; + }; + } // namespace + + spvtools::opt::Pass::Status DemoteFloat64Pass::Process() { + auto* irContext = context(); + auto* defUseMgr = irContext->get_def_use_mgr(); + + // --- 1. the leaf types ------------------------------------------------------- + std::vector float64Types; + for (Instruction& type : irContext->types_values()) { + if (IsFloatTypeOfWidth(type, kFloat64Width)) { + float64Types.push_back(&type); + } + } + if (float64Types.empty()) return Status::SuccessWithoutChange; + + std::unordered_set float64TypeIds; + for (const Instruction* type : float64Types) { + float64TypeIds.insert(type->result_id()); + } + + // Every type a *value* can have that is 64-bit float underneath: the scalar itself + // plus the vectors and matrices built from it. The types-and-values section is in + // declaration order and SPIR-V forbids a type from forward-referencing another, so + // one forward walk is already the complete transitive closure. Aggregates and + // pointers are deliberately not included: no operand of the instructions checked + // below is ever an aggregate or a pointer. + std::unordered_set float64ValueTypeIds = float64TypeIds; + for (Instruction& type : irContext->types_values()) { + if (type.opcode() != spv::Op::OpTypeVector && type.opcode() != spv::Op::OpTypeMatrix) { + continue; + } + if (float64ValueTypeIds.count(type.GetSingleWordInOperand(0)) != 0) { + float64ValueTypeIds.insert(type.result_id()); + } + } + + // Every type that has a 64-bit float anywhere underneath it, which is exactly the + // set of blocks whose layout has to be re-derived once the leaves narrow. Computed + // now, before the rewrite makes a demoted float indistinguishable from one that was + // always 32 bits; the same single forward walk is a complete closure. + std::unordered_set wideTypeIds = float64TypeIds; + for (Instruction& type : irContext->types_values()) { + const auto contains = [&](Uint32 operand) { + return wideTypeIds.count(type.GetSingleWordInOperand(operand)) != 0; + }; + switch (type.opcode()) { + case spv::Op::OpTypeVector: + case spv::Op::OpTypeMatrix: + case spv::Op::OpTypeArray: + case spv::Op::OpTypeRuntimeArray: + if (contains(0)) wideTypeIds.insert(type.result_id()); + break; + case spv::Op::OpTypePointer: + if (contains(1)) wideTypeIds.insert(type.result_id()); + break; + case spv::Op::OpTypeStruct: + for (Uint32 member = 0; member < type.NumInOperands(); ++member) { + if (contains(member)) { + wideTypeIds.insert(type.result_id()); + break; + } + } + break; + default: break; + } + } + + const auto valueIsFloat64 = [&](Uint32 id) { + const Instruction* def = defUseMgr->GetDef(id); + return def != nullptr && float64ValueTypeIds.count(def->type_id()) != 0; + }; + + // --- 2. decline before touching anything ------------------------------------ + // These are the operations that mean "the 64 bits themselves", not "a wide float". + // Narrowing one side of them produces a module spirv-val rejects, and rebuilding + // the value would mean emulating fp64 in software. Bail out with the module + // byte-identical instead; the caller still has its "module declares Float64" + // diagnostic for it. + Uint32 glslStd450SetId = 0; + for (const Instruction& import : irContext->ext_inst_imports()) { + if (import.GetInOperand(0).AsString() == "GLSL.std.450") { + glslStd450SetId = import.result_id(); + break; + } + } + + const char* declineReason = nullptr; + irContext->module()->ForEachInst( + [&](Instruction* inst) { + if (declineReason != nullptr) return; + switch (inst->opcode()) { + case spv::Op::OpBitcast: { + // "Total bit width of Result Type and Operand must match" - true + // today, false the moment one of the two sides halves. + const Bool resultIs64 = float64ValueTypeIds.count(inst->type_id()) != 0; + const Bool operandIs64 = valueIsFloat64(inst->GetSingleWordInOperand(0)); + if (resultIs64 != operandIs64) { + declineReason = "an OpBitcast across the 64-bit boundary " + "(doubleBitsToUint64 / uint64BitsToDouble / " + "packDouble2x32)"; + } + break; + } + case spv::Op::OpExtInst: { + if (glslStd450SetId == 0 || + inst->GetSingleWordInOperand(0) != glslStd450SetId) { + break; + } + const Uint32 extOpcode = inst->GetSingleWordInOperand(1); + if (extOpcode == GLSLstd450PackDouble2x32 || + extOpcode == GLSLstd450UnpackDouble2x32) { + declineReason = "GLSL.std.450 PackDouble2x32 / UnpackDouble2x32, " + "which are defined only for a 64-bit float"; + } + break; + } + default: break; + } + }, + /*run_on_debug_line_insts=*/false); + + if (declineReason != nullptr) { + MGLOG_D("DemoteFloat64Pass: declined - the module uses %s; its 64-bit floats are " + "left in place", + declineReason); + return Status::SuccessWithoutChange; + } + + // --- 3. re-encode the literals ---------------------------------------------- + // A 64-bit float constant carries two literal words and a 32-bit one carries a + // single word, so the value has to be narrowed before the type changes underneath + // it - afterwards there is no way left to tell how wide the literal was meant to + // be. Composite constants hold s, not literals, and need nothing. + for (Instruction& value : irContext->types_values()) { + if (value.opcode() != spv::Op::OpConstant && value.opcode() != spv::Op::OpSpecConstant) { + continue; + } + if (float64TypeIds.count(value.type_id()) == 0) continue; + if (value.NumInOperands() < 1) continue; + const Operand& literal = value.GetInOperand(0); + if (literal.words.size() != 2) continue; + + const Uint64 bits = + (static_cast(literal.words[1]) << 32) | static_cast(literal.words[0]); + double wide = 0.0; + std::memcpy(&wide, &bits, sizeof(wide)); + // Deliberately the ordinary narrowing conversion: a magnitude no float can + // hold becomes an infinity, which is the same answer the demoted arithmetic + // around it would produce. + const float narrow = static_cast(wide); + Uint32 narrowedBits = 0; + std::memcpy(&narrowedBits, &narrow, sizeof(narrowedBits)); + + Operand narrowedLiteral = literal; + narrowedLiteral.words = {narrowedBits}; + value.SetInOperands({std::move(narrowedLiteral)}); + } + + // --- 4. the demotion itself -------------------------------------------------- + // In place, so every composite type, every pointer, every struct member offset and + // every debug name that referred to the 64-bit type keeps referring to the same + // . This is the whole reason the pass does not build parallel types. + for (Instruction* type : float64Types) { + type->SetInOperand(0, {kFloat32Width}); + } + // The cached analysis::Type objects were built against the old widths. + irContext->InvalidateAnalyses(IRContext::kAnalysisTypes); + + // --- 5. the conversions that just became identities -------------------------- + // `float(someDouble)` and `double(someFloat)` are both OpFConvert, and SPIR-V + // requires the two component widths to differ. Both sides are 32 bits now, so each + // one is replaced by its operand. Walking in module order means a chain of them + // resolves in a single sweep: by the time the second is reached its operand has + // already been rewritten to the ultimate source. + const auto componentWidth = [&](Uint32 typeId) -> Uint32 { + const Instruction* def = defUseMgr->GetDef(typeId); + if (def == nullptr) return 0; + if (def->opcode() == spv::Op::OpTypeVector) { + def = defUseMgr->GetDef(def->GetSingleWordInOperand(0)); + } + if (def == nullptr || def->opcode() != spv::Op::OpTypeFloat) return 0; + return def->GetSingleWordInOperand(0); + }; + + std::vector identityConversions; + irContext->module()->ForEachInst( + [&](Instruction* inst) { + if (inst->opcode() != spv::Op::OpFConvert) return; + const Instruction* operandDef = defUseMgr->GetDef(inst->GetSingleWordInOperand(0)); + if (operandDef == nullptr) return; + const Uint32 resultWidth = componentWidth(inst->type_id()); + if (resultWidth == 0 || resultWidth != componentWidth(operandDef->type_id())) { + return; + } + identityConversions.push_back(inst); + }, + /*run_on_debug_line_insts=*/false); + + for (Instruction* conversion : identityConversions) { + irContext->ReplaceAllUsesWith(conversion->result_id(), + conversion->GetSingleWordInOperand(0)); + irContext->KillInst(conversion); + } + + // --- 6. the capability ------------------------------------------------------- + std::vector deadCapabilities; + for (Instruction& capability : irContext->capabilities()) { + if (static_cast(capability.GetSingleWordInOperand(0)) == + spv::Capability::Float64) { + deadCapabilities.push_back(&capability); + } + } + for (Instruction* capability : deadCapabilities) { + irContext->KillInst(capability); + } + + // --- 7. merge what the rewrite made into a duplicate ------------------------- + // `double` and `float` are now the same declaration, and so is every vector, + // matrix, pointer and function type spelled in terms of them. Walking the section + // in declaration order and replacing each duplicate the moment it is found means + // the later types are already canonical by the time they are keyed: SPIR-V forbids + // a type from forward-referencing another, so every operand of the instruction + // being looked at has been through this loop already. + std::map, Uint32> keptTypeByShape; + for (Instruction* type = &*irContext->types_values_begin(); type != nullptr;) { + Instruction* next = type->NextNode(); + if (!IsDuplicableType(type->opcode())) { + type = next; + continue; + } + + std::vector shape{static_cast(type->opcode())}; + for (Uint32 i = 0; i < type->NumInOperands(); ++i) { + const Operand& operand = type->GetInOperand(i); + shape.insert(shape.end(), operand.words.begin(), operand.words.end()); + } + + const auto kept = keptTypeByShape.find(shape); + if (kept == keptTypeByShape.end()) { + keptTypeByShape.emplace(std::move(shape), type->result_id()); + type = next; + continue; + } + + irContext->KillNamesAndDecorates(type->result_id()); + irContext->ReplaceAllUsesWith(type->result_id(), kept->second); + irContext->KillInst(type); + type = next; + } + + // --- 8. re-derive the layout of every block that held a 64-bit float ----------- + // See BlockRelayout for why this is a recomputation and not a preservation. Only + // blocks that actually narrowed are touched: relaying out an untouched block would + // be pure churn, and would risk disagreeing with glslang over a layout that was + // already correct. + for (Instruction& variable : irContext->types_values()) { + if (variable.opcode() != spv::Op::OpVariable) continue; + const auto storageClass = + static_cast(variable.GetSingleWordInOperand(0)); + if (storageClass != spv::StorageClass::Uniform && + storageClass != spv::StorageClass::StorageBuffer && + storageClass != spv::StorageClass::PushConstant) { + continue; + } + const Instruction* pointerType = defUseMgr->GetDef(variable.type_id()); + if (pointerType == nullptr) continue; + + // An arrayed block (`uniform Blk { ... } blocks[4];`) is a pointer to an array + // of the struct; the layout lives on the struct either way. + const Instruction* blockType = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(1)); + while (blockType != nullptr && (blockType->opcode() == spv::Op::OpTypeArray || + blockType->opcode() == spv::Op::OpTypeRuntimeArray)) { + blockType = defUseMgr->GetDef(blockType->GetSingleWordInOperand(0)); + } + if (blockType == nullptr || blockType->opcode() != spv::Op::OpTypeStruct) continue; + if (wideTypeIds.count(blockType->result_id()) == 0) continue; + + // A storage block packs std430, a uniform block std140. Before SPIR-V 1.3 a + // storage block was a Uniform-storage variable whose struct carried + // BufferBlock, so the decoration decides rather than the storage class alone. + Bool isStorageBlock = storageClass == spv::StorageClass::StorageBuffer; + for (const Instruction& annotation : irContext->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate) continue; + if (annotation.GetSingleWordInOperand(0) != blockType->result_id()) continue; + if (static_cast(annotation.GetSingleWordInOperand(1)) == + spv::Decoration::BufferBlock) { + isStorageBlock = true; + } + } + + BlockRelayout relayout(irContext, /*std140=*/!isStorageBlock); + if (relayout.Measure(blockType->result_id()).size == 0) { + // A member shape the layout rules here do not describe. Leaving the block + // at its 64-bit offsets keeps the module valid for Vulkan; SPIRV-Cross will + // decline it for ESSL, which is the same outcome as before the demotion. + MGLOG_D("DemoteFloat64Pass: block %%%u contains a member this pass cannot lay " + "out; its 64-bit offsets are left in place", + blockType->result_id()); + } + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken DemoteFloat64Pass::CreateDemoteFloat64Pass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h new file mode 100644 index 00000000..73e6d822 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h @@ -0,0 +1,84 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.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 { + // Rewrites every 64-bit float in the module to a 32-bit one: `OpTypeFloat 64` becomes + // `OpTypeFloat 32` in place, so every vector, matrix, array, struct, pointer and + // function type that named it keeps its and every decoration attached to it, and + // only the meaning of the leaf type changes. The double literals are re-encoded, the + // now-width-preserving OpFConvert pairs collapse to their operand, and the Float64 + // capability goes away. + // + // Why demote at all: no mobile GPU has it. Adreno and Mali both report + // VkPhysicalDeviceFeatures::shaderFloat64 == VK_FALSE (the Magma POST has a row for it), + // so a module declaring Float64 cannot become a pipeline there; and ESSL has no 64-bit + // float type at all, so SPIRV-Cross throws "FP64 not supported in ES profile" and the + // Espryt path never even reaches the driver. Demotion is what makes `double` in an + // application's GLSL compile and run everywhere, at fp32 precision. + // + // BLOCK LAYOUT IS RE-DERIVED, NOT PRESERVED, and that was not the first choice - see + // BlockRelayout in the .cpp for the measurement that forced it. Preserving the 64-bit + // offsets (float + 4 bytes of padding in each slot) keeps the application's byte layout + // intact and is what a Vulkan-only implementation would do, but GLSL ES has no member + // `layout(offset=)`, so SPIRV-Cross recomputes std140/std430 from the declared types + // and refuses any block whose stated offsets disagree - "Buffer block cannot be + // expressed as any of std430, std140, scalar". Every shader with a double in a block + // would then fail to transpile for Espryt at all, which is the case this demotion + // exists to fix. Nor can padding members rescue it: a dmat4 member carries + // MatrixStride 32 and std140 requires 16 for the demoted mat4, and padding BETWEEN + // members cannot change a stride INSIDE one. + // + // What re-deriving costs, stated plainly: a block that held 64-bit members changes its + // driver-visible byte layout, so an application that hard-codes std140 offsets it + // computed for doubles addresses the wrong bytes. Applications that query their offsets + // are unaffected. MobileGL's own default-uniform block is unaffected by construction: + // the frontend builds its uniform routing by reflecting the module this pass produced + // (ProgramSpirvTask::BuildGlobalUboRouting), so glUniform*d - which narrows to float + // for the same reason - writes exactly where the demoted shader reads. Blocks with no + // 64-bit member anywhere are never touched. + // + // Declines (leaves the module byte-identical, so the caller's existing "this module + // still declares Float64" failure path reports it) when the module contains an + // operation whose validity depends on the operand really being 64 bits wide: + // - OpBitcast across the boundary - packDouble2x32 / doubleBitsToUint64 and friends, + // where SPIR-V requires both sides to have the same total bit width; + // - GLSL.std.450 PackDouble2x32 / UnpackDouble2x32, which are defined only for a + // 64-bit float result/operand. + // + // ORDERING: must run before PackDoubleVertexInputsPass, which introduces exactly the + // OpBitcast this pass declines on. After demotion no 64-bit vertex input is left, so + // that pass becomes a no-op rather than a conflict. + // + // The in-place rewrite creates duplicate type declarations by construction - a module + // that had both `double` and `float` ends up with two `OpTypeFloat 32`, and spirv-val + // rejects that ("Duplicate non-aggregate type declarations are not allowed") - so the + // pass merges them itself afterwards. Deliberately NOT by registering spvtools' + // RemoveDuplicates alongside it: that pass also merges structurally identical STRUCTS + // and calls KillNamesAndDecorates on the loser, which would silently delete the OpName + // of one of two distinct-but-identically-shaped interface blocks - and OpName is how + // MobileGL resolves block and varying names. Only the non-aggregate types spirv-val + // actually forbids duplicates of are merged here; arrays and structs are left alone, + // which also keeps a `double[]`'s ArrayStride from being merged into a `float[]`'s. + class DemoteFloat64Pass : public spvtools::opt::Pass { + public: + const char* name() const override { return "mobilegl-demote-float64"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateDemoteFloat64Pass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL From 62a2dae5ba9caf26bc5b7189ce1d493531ff3411 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 06:20:11 -0400 Subject: [PATCH 2/5] [Fix, Test] (MG_Impl, MG_State, MG_IntegrationTest): glUniform*d stores what the demoted shader reads --- .../MG_Impl/GLImpl/Program/GL_Program.cpp | 142 +++++--- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../Scenarios/DoublePrecisionScenario.cpp | 318 ++++++++++++++++++ .../GLState/ProgramState/ProgramObject.h | 19 +- 4 files changed, 418 insertions(+), 62 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index d02e2956..eeea664b 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -909,7 +909,13 @@ namespace MobileGL::MG_Impl::GLImpl { } if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) { - Memcpy(params, pUBO + offset, size); + // Never more than the uniform actually occupies. `size` is the GL type size, + // which for a `double` uniform is twice its storage - every 64-bit float is + // narrowed before the module reaches a backend, so the slot holds floats. The + // typed entry points (glGetUniformdv and friends) go through + // GetUniformScalar_State, which converts component by component; this raw + // copy has no type to convert with, so it is bounded rather than converted. + Memcpy(params, pUBO + offset, std::min(size, span)); } } // TODO: handle 1i variant as texture unit @@ -960,22 +966,27 @@ namespace MobileGL::MG_Impl::GLImpl { if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return; } - // A double-precision uniform is the one case where the stored component type can - // differ from the queried one for a non-opaque uniform, and the difference is not - // just a reinterpretation: it is twice as wide, so a raw copy would overrun the - // caller's buffer as well as return nonsense. Read component by component and let - // GL's conversion rules (7.6: round to nearest for the integer queries) apply. + // A double-precision uniform is the one case where the stored component type differs + // from the DECLARED one for a non-opaque uniform: the shader's 64-bit floats are + // narrowed to 32 bits before the module reaches a backend + // (ShaderTranspiler::DemoteFloat64Pass), so what is in the global UBO is a float per + // component, laid out exactly like the float-typed twin of this uniform - std140 + // 16-byte column stride for a matrix included. Reading it as a GLdouble would return + // two components reinterpreted as one. Read component by component and let GL's + // conversion rules (7.6: round to nearest for the integer queries) apply; the value + // widens back to the queried type, having lost precision at the glUniform*d that + // stored it and not here. if (ttype->getBasicType() == glslang::EbtDouble) { const Int columns = ttype->isMatrix() ? ttype->getMatrixCols() : 1; const Int rows = ttype->isMatrix() ? ttype->getMatrixRows() : (ttype->isVector() ? ttype->getVectorSize() : 1); - // The slot the linker handed out is exactly `columns` columns wide, so it also - // states the column stride - which for a double matrix is not a float's 16 bytes. - const SizeT columnStride = columns > 0 ? size / static_cast(columns) : size; + // std140 gives every matrix column its own 16-byte slot; a non-matrix is one + // tightly packed run and never reaches the stride at all. + const SizeT columnStride = 4 * sizeof(GLfloat); for (Int column = 0; column < columns; ++column) { for (Int row = 0; row < rows; ++row) { - GLdouble component = 0.0; - Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLdouble), + GLfloat component = 0.0f; + Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLfloat), sizeof(component)); if constexpr (std::is_integral_v) { // Rounded to the nearest integer and clamped into the queried type's @@ -1248,36 +1259,39 @@ namespace MobileGL::MG_Impl::GLImpl { } } - // glUniform*d / glUniformMatrix*dv. The vector forms need nothing beyond the shared - // upload template - it is already typed on the component - but a matrix does: the - // column stride the linker used for a double matrix is not the 16 bytes a float one - // gets. It is not guessed here; the slot the uniform was given is exactly `columns` - // columns wide, so dividing states the stride the rest of the pipeline agreed on. - template - void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose, - const GLdouble* value, Int columns, Int rows) { - const SizeT slotSize = programObject.GetUniformSizesInBytes(location); - const SizeT columnStride = columns > 0 ? slotSize / static_cast(columns) : slotSize; - const SizeT componentCount = static_cast(columns) * static_cast(rows); - Vector column(static_cast(rows)); - for (GLint matrix = 0; matrix < count; ++matrix) { - if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) break; - if (!programObject.IsValidUniformLocation(location + matrix)) { - RecordInvalidUniformLocationError(__func__, location + matrix, "the current program object"); - return; - } - const GLdouble* source = value + matrix * componentCount; - for (Int c = 0; c < columns; ++c) { - for (Int r = 0; r < rows; ++r) { - column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r]; - } - Uniform_State<1>(programObject, location + matrix, column.data(), c * columnStride); - for (Int r = 1; r < rows; ++r) { - Uniform_State<1>(programObject, location + matrix, column.data() + r, - c * columnStride + r * sizeof(GLdouble)); - } - } + // glUniform*d / glUniformMatrix*dv. Neither needs a layout of its own any more: the + // transpile chain narrows every 64-bit float in the shader to 32 bits + // (ShaderTranspiler::DemoteFloat64Pass) and the global UBO is laid out by reflecting that + // demoted module, so a double uniform's storage IS a float uniform's - same offset, same + // 4-byte components, same std140 column padding for matrices. Narrowing here, at the one + // place the 64-bit value enters, and then handing the bytes to the ordinary float upload + // path is what keeps the two in step; a separate double-shaped layout here would write + // 8-byte components into 4-byte slots and silently address the wrong ones. + // + // The narrowing is the same static_cast the shader's own arithmetic now performs, so the + // value the shader reads is the value glUniform*d was given, at float precision. + template + void UniformvNarrowed_State(GLint location, GLsizei count, const GLdouble* value) { + if (value == nullptr || count <= 0) { + // Same shape as the float entry points: the location validation still runs, and a + // null pointer is left to fault exactly where glUniform*fv would. + Uniformv_State(location, count, reinterpret_cast(value)); + return; } + Vector narrowed(static_cast(count) * ItemCount); + for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); + Uniformv_State(location, count, narrowed.data()); + } + + template + void ProgramUniformvNarrowed_State(GLuint program, GLint location, GLsizei count, const GLdouble* value) { + if (value == nullptr || count <= 0) { + ProgramUniformv_State(program, location, count, reinterpret_cast(value)); + return; + } + Vector narrowed(static_cast(count) * ItemCount); + for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); + ProgramUniformv_State(program, location, count, narrowed.data()); } // glUniformMatrix*fv / glProgramUniformMatrix*fv, every shape (square and non-square). @@ -1326,6 +1340,22 @@ namespace MobileGL::MG_Impl::GLImpl { } } + // glUniformMatrix*dv / glProgramUniformMatrix*dv. Narrowed to the float form and handed + // straight to it: after DemoteFloat64Pass a `dmat4` uniform is a `mat4` in the shader and a + // mat4-shaped slot in the global UBO, columns padded to a vec4 and all. Everything else + // about the call - transpose handling, the array-element walk, the opaque-uniform refusal - + // is then the one implementation both spellings share. + template + void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose, + const GLdouble* value, Int columns, Int rows) { + if (value == nullptr || count <= 0) return; + const SizeT componentCount = static_cast(columns) * static_cast(rows); + Vector narrowed(static_cast(count) * componentCount); + for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); + UniformMatrixfv_Object(programObject, "glUniformMatrixdv", location, count, transpose, narrowed.data(), + columns, rows, "the current program object"); + } + // Helper function to transpose a 2x2 matrix void TransposeMatrix2x2(const GLfloat* input, GLfloat* output) { // Input matrix is in column-major order (OpenGL default) @@ -2089,71 +2119,71 @@ namespace MobileGL::MG_Impl::GLImpl { } void Uniform1d(GLint location, GLdouble v0) { const GLdouble v[] = {v0}; - Uniformv_State<1>(location, 1, v); + UniformvNarrowed_State<1>(location, 1, v); } void Uniform1dv(GLint location, GLsizei count, const GLdouble* value) { - Uniformv_State<1>(location, count, value); + UniformvNarrowed_State<1>(location, count, value); } void ProgramUniform1d(GLuint program, GLint location, GLdouble v0) { const GLdouble v[] = {v0}; - ProgramUniformv_State<1>(program, location, 1, v); + ProgramUniformvNarrowed_State<1>(program, location, 1, v); } void ProgramUniform1dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) { - ProgramUniformv_State<1>(program, location, count, value); + ProgramUniformvNarrowed_State<1>(program, location, count, value); } void Uniform2d(GLint location, GLdouble v0, GLdouble v1) { const GLdouble v[] = {v0, v1}; - Uniformv_State<2>(location, 1, v); + UniformvNarrowed_State<2>(location, 1, v); } void Uniform2dv(GLint location, GLsizei count, const GLdouble* value) { - Uniformv_State<2>(location, count, value); + UniformvNarrowed_State<2>(location, count, value); } void ProgramUniform2d(GLuint program, GLint location, GLdouble v0, GLdouble v1) { const GLdouble v[] = {v0, v1}; - ProgramUniformv_State<2>(program, location, 1, v); + ProgramUniformvNarrowed_State<2>(program, location, 1, v); } void ProgramUniform2dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) { - ProgramUniformv_State<2>(program, location, count, value); + ProgramUniformvNarrowed_State<2>(program, location, count, value); } void Uniform3d(GLint location, GLdouble v0, GLdouble v1, GLdouble v2) { const GLdouble v[] = {v0, v1, v2}; - Uniformv_State<3>(location, 1, v); + UniformvNarrowed_State<3>(location, 1, v); } void Uniform3dv(GLint location, GLsizei count, const GLdouble* value) { - Uniformv_State<3>(location, count, value); + UniformvNarrowed_State<3>(location, count, value); } void ProgramUniform3d(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2) { const GLdouble v[] = {v0, v1, v2}; - ProgramUniformv_State<3>(program, location, 1, v); + ProgramUniformvNarrowed_State<3>(program, location, 1, v); } void ProgramUniform3dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) { - ProgramUniformv_State<3>(program, location, count, value); + ProgramUniformvNarrowed_State<3>(program, location, count, value); } void Uniform4d(GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3) { const GLdouble v[] = {v0, v1, v2, v3}; - Uniformv_State<4>(location, 1, v); + UniformvNarrowed_State<4>(location, 1, v); } void Uniform4dv(GLint location, GLsizei count, const GLdouble* value) { - Uniformv_State<4>(location, count, value); + UniformvNarrowed_State<4>(location, count, value); } void ProgramUniform4d(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3) { const GLdouble v[] = {v0, v1, v2, v3}; - ProgramUniformv_State<4>(program, location, 1, v); + ProgramUniformvNarrowed_State<4>(program, location, 1, v); } void ProgramUniform4dv(GLuint program, GLint location, GLsizei count, const GLdouble* value) { - ProgramUniformv_State<4>(program, location, count, value); + ProgramUniformvNarrowed_State<4>(program, location, count, value); } void UniformMatrix2dv(GLint location, GLsizei count, GLboolean transpose, const GLdouble* value) { if (location == -1) return; diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index bc17ff79..b6a520cb 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -61,6 +61,7 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearThenReadPixelsScenario.cpp Scenarios/DepthStencilReadbackScenario.cpp Scenarios/SsboArrayLengthScenario.cpp + Scenarios/DoublePrecisionScenario.cpp Scenarios/UniformInitializerScenario.cpp Scenarios/SwizzleAccessRoutineScenario.cpp Scenarios/ProgramPipelineScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp new file mode 100644 index 00000000..51c37a61 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp @@ -0,0 +1,318 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.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 +// +// Scenario - GLSL DOUBLES, RUN AT SINGLE PRECISION. +// +// No mobile GPU has 64-bit floats. Adreno and Mali both report shaderFloat64 == VK_FALSE, so +// Magma cannot build a module that declares the Float64 capability, and ESSL has no fp64 type +// at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES +// profile") and the program never reaches the driver. MobileGL therefore narrows every 64-bit +// float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than declining the +// shader: `double` compiles and runs everywhere, at float precision. +// +// The narrowing is only half a contract. The other half is the API side: the global UBO is +// laid out by reflecting the DEMOTED module, so glUniform*d has to store a float where the +// shader reads a float, glGetUniform*v has to read one back, and a dmat4's columns are now +// std140-padded like any other matrix's. Every one of those is a byte offset that fails +// silently - the uniform simply reads as something else - so the cases below set values +// through the API and have the SHADER report what it saw. +// +// What is deliberately NOT asserted: that the values are exact to double precision. They are +// not, and cannot be. Every expectation here is the float value of the double that was set, +// which is the whole point. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Doubles in every shape the demotion has to handle - a scalar, a vector, a matrix + // whose column stride changes, an array whose element stride changes - all reported + // through one float SSBO so a single readback says which one moved. + constexpr const char* kComputeSource = R"(#version 430 core +layout(local_size_x = 1) in; +uniform double uScalar; +uniform dvec3 uVector; +uniform dmat4 uMatrix; +uniform double uArray[3]; +layout(std430, binding = 0) buffer Output { + float g_out[]; +}; +void main() { + g_out[0] = float(uScalar); + g_out[1] = float(uVector.x); + g_out[2] = float(uVector.y); + g_out[3] = float(uVector.z); + // Column-major [column][row]. Off-diagonal entries catch a column-stride mistake that a + // diagonal-only check reads straight past. + g_out[4] = float(uMatrix[0][0]); + g_out[5] = float(uMatrix[0][3]); + g_out[6] = float(uMatrix[3][0]); + g_out[7] = float(uMatrix[3][3]); + g_out[8] = float(uArray[0]); + g_out[9] = float(uArray[1]); + g_out[10] = float(uArray[2]); + // Arithmetic on doubles, including an implicit float->double conversion and a literal + // with the fp64 suffix: this is what an application actually writes, and it is the part + // that has to survive the conversion folding. + double accumulated = uScalar * 2.0lf + 1.5; + g_out[11] = float(accumulated); +} +)"; + + constexpr int kOutputSlots = 12; + + class DoublePrecisionScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_program = CompileComputeProgram(kComputeSource); + ASSERT_NE(m_program, 0u) << m_buildLog; + + glGenBuffers(1, &m_output); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output); + const std::vector zeroes(kOutputSlots, 0.0f); + glBufferData(GL_SHADER_STORAGE_BUFFER, kOutputSlots * sizeof(float), zeroes.data(), + GL_DYNAMIC_DRAW); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_output); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + } + + void TearDown() override { + if (!Ready()) return; + if (m_output != 0) glDeleteBuffers(1, &m_output); + if (m_program != 0) glDeleteProgram(m_program); + } + + unsigned int CompileComputeProgram(const char* source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute shader did not compile: ") + log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + m_buildLog = std::string("compute program did not link: ") + log; + glDeleteProgram(program); + return 0; + } + return program; + } + + std::vector Dispatch() { + glUseProgram(m_program); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + std::vector values(kOutputSlots, -1.0f); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, kOutputSlots * sizeof(float), values.data()); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + glUseProgram(0); + return values; + } + + unsigned int m_program = 0; + unsigned int m_output = 0; + std::string m_buildLog; + }; + + TEST_F(DoublePrecisionScenario, ADoubleUniformReachesTheShaderAtFloatPrecision) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint scalar = glGetUniformLocation(m_program, "uScalar"); + ASSERT_GE(scalar, 0); + // 0.1 has no exact float (or double) representation, so this only passes if the + // value really travelled through the demoted slot rather than being read out of + // some other four bytes. + glUniform1d(scalar, 0.1); + glUseProgram(0); + + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + EXPECT_FLOAT_EQ(values[0], static_cast(0.1)); + EXPECT_FLOAT_EQ(values[11], static_cast(static_cast(0.1) * 2.0f + 1.5f)) + << "arithmetic on the demoted value, including the folded fp64 literal"; + } + + TEST_F(DoublePrecisionScenario, EveryDoubleShapeLandsInItsOwnSlot) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint scalar = glGetUniformLocation(m_program, "uScalar"); + const GLint vector = glGetUniformLocation(m_program, "uVector"); + const GLint matrix = glGetUniformLocation(m_program, "uMatrix"); + const GLint array0 = glGetUniformLocation(m_program, "uArray[0]"); + const GLint array2 = glGetUniformLocation(m_program, "uArray[2]"); + ASSERT_GE(scalar, 0); + ASSERT_GE(vector, 0); + ASSERT_GE(matrix, 0); + ASSERT_GE(array0, 0); + ASSERT_GE(array2, 0); + + glUniform1d(scalar, 5.0); + const GLdouble vectorValue[3] = {11.0, 12.0, 13.0}; + glUniform3dv(vector, 1, vectorValue); + // Column-major, and every entry distinct so a transposed or mis-strided write + // cannot land on a value that happens to match. + GLdouble matrixValue[16] = {}; + for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i; + glUniformMatrix4dv(matrix, 1, GL_FALSE, matrixValue); + const GLdouble arrayValue[3] = {71.0, 72.0, 73.0}; + glUniform1dv(array0, 3, arrayValue); + glUseProgram(0); + + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + EXPECT_FLOAT_EQ(values[0], 5.0f) << "scalar double"; + EXPECT_FLOAT_EQ(values[1], 11.0f) << "dvec3 .x"; + EXPECT_FLOAT_EQ(values[2], 12.0f) << "dvec3 .y"; + EXPECT_FLOAT_EQ(values[3], 13.0f) << "dvec3 .z"; + EXPECT_FLOAT_EQ(values[4], 100.0f) << "dmat4 [0][0]"; + EXPECT_FLOAT_EQ(values[5], 103.0f) << "dmat4 [0][3] - within the first column"; + EXPECT_FLOAT_EQ(values[6], 112.0f) << "dmat4 [3][0] - column stride"; + EXPECT_FLOAT_EQ(values[7], 115.0f) << "dmat4 [3][3]"; + EXPECT_FLOAT_EQ(values[8], 71.0f) << "double array element 0"; + EXPECT_FLOAT_EQ(values[9], 72.0f) << "double array element 1 - element stride"; + EXPECT_FLOAT_EQ(values[10], 73.0f) << "double array element 2"; + } + + TEST_F(DoublePrecisionScenario, TheTransposeFlagStillTransposes) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint matrix = glGetUniformLocation(m_program, "uMatrix"); + ASSERT_GE(matrix, 0); + GLdouble matrixValue[16] = {}; + for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i; + glUniformMatrix4dv(matrix, 1, GL_TRUE, matrixValue); + glUseProgram(0); + + const std::vector values = Dispatch(); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + // Transposed, so [column][row] now reads the source's [row][column]. + EXPECT_FLOAT_EQ(values[4], 100.0f) << "dmat4 [0][0] is on the diagonal either way"; + EXPECT_FLOAT_EQ(values[5], 112.0f) << "dmat4 [0][3] after transpose"; + EXPECT_FLOAT_EQ(values[6], 103.0f) << "dmat4 [3][0] after transpose"; + EXPECT_FLOAT_EQ(values[7], 115.0f) << "dmat4 [3][3] is on the diagonal either way"; + } + + TEST_F(DoublePrecisionScenario, TheUniformIsStillReportedAsADouble) { + if (!Ready()) return; + // The demotion is an implementation detail of how the value is STORED. What the + // shader source declared is what the application asked about, so the reflection + // keeps answering GL_DOUBLE* - an application that switches on the type and calls + // glUniform*d has to keep working, and it is the glUniform*d path that is correct + // for these uniforms. + struct Expectation { + const char* name; + GLenum type; + GLint size; + }; + const Expectation expectations[] = { + {"uScalar", GL_DOUBLE, 1}, + {"uVector", GL_DOUBLE_VEC3, 1}, + {"uMatrix", GL_DOUBLE_MAT4, 1}, + {"uArray[0]", GL_DOUBLE, 3}, + }; + + GLint activeUniforms = 0; + glGetProgramiv(m_program, GL_ACTIVE_UNIFORMS, &activeUniforms); + ASSERT_GT(activeUniforms, 0); + + for (const Expectation& expectation : expectations) { + bool found = false; + for (GLint index = 0; index < activeUniforms; ++index) { + char name[128] = {}; + GLsizei length = 0; + GLint size = 0; + GLenum type = 0; + glGetActiveUniform(m_program, static_cast(index), sizeof(name) - 1, &length, &size, + &type, name); + if (std::string(name, static_cast(length)) != expectation.name) continue; + found = true; + EXPECT_EQ(type, expectation.type) << expectation.name; + EXPECT_EQ(size, expectation.size) << expectation.name; + break; + } + EXPECT_TRUE(found) << "glGetActiveUniform never reported " << expectation.name; + } + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + + TEST_F(DoublePrecisionScenario, GetUniformdvReadsBackWhatWasStored) { + if (!Ready()) return; + glUseProgram(m_program); + const GLint scalar = glGetUniformLocation(m_program, "uScalar"); + const GLint vector = glGetUniformLocation(m_program, "uVector"); + const GLint matrix = glGetUniformLocation(m_program, "uMatrix"); + ASSERT_GE(scalar, 0); + ASSERT_GE(vector, 0); + ASSERT_GE(matrix, 0); + glUniform1d(scalar, 0.1); + const GLdouble vectorValue[3] = {11.5, 12.5, 13.5}; + glUniform3dv(vector, 1, vectorValue); + GLdouble matrixValue[16] = {}; + for (int i = 0; i < 16; ++i) matrixValue[i] = 100.0 + i; + glUniformMatrix4dv(matrix, 1, GL_FALSE, matrixValue); + glUseProgram(0); + + // The readback has to undo exactly what the write did - the same std140 column + // padding, the same 4-byte components - or a dmat4 comes back with its columns + // shifted and nothing else in the API would say so. + GLdouble readScalar = 0.0; + glGetUniformdv(m_program, scalar, &readScalar); + EXPECT_DOUBLE_EQ(readScalar, static_cast(static_cast(0.1))) + << "the value is what a float can hold, not the double that was passed in"; + + GLdouble readVector[3] = {}; + glGetUniformdv(m_program, vector, readVector); + EXPECT_DOUBLE_EQ(readVector[0], 11.5); + EXPECT_DOUBLE_EQ(readVector[1], 12.5); + EXPECT_DOUBLE_EQ(readVector[2], 13.5); + + GLdouble readMatrix[16] = {}; + glGetUniformdv(m_program, matrix, readMatrix); + for (int i = 0; i < 16; ++i) { + EXPECT_DOUBLE_EQ(readMatrix[i], 100.0 + i) << "dmat4 component " << i; + } + + // The float query sees the same storage through the type it is actually stored as. + GLfloat readFloat = 0.0f; + glGetUniformfv(m_program, scalar, &readFloat); + EXPECT_FLOAT_EQ(readFloat, static_cast(0.1)); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index da40d644..515a9a2e 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -326,15 +326,22 @@ namespace MobileGL::MG_State::GLState { : kInvalidUniformOffset; } Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } - // Bytes a uniform actually occupies in the global UBO, which is not its GL type size: - // std140 pads each column of a float matrix out to a vec4, so a mat3 spans 48 bytes - // even though only 36 of them carry components. Anything reading or writing a whole - // uniform's storage - a bounds check, a copy between two programs' shadows - wants - // this rather than GetUniformSizesInBytes. + // Bytes a uniform actually occupies in the global UBO, which is not its GL type size, + // for two reasons. std140 pads each column of a matrix out to a vec4, so a mat3 spans + // 48 bytes even though only 36 of them carry components. And every 64-bit float in a + // shader is narrowed to 32 bits before the module reaches a backend + // (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting that + // demoted module - so a `double` uniform occupies exactly what its float-typed twin + // would, half its GL type size, and a `dmat4` is padded like any other matrix. Anything + // reading or writing a whole uniform's storage - a bounds check, a copy between two + // programs' shadows - wants this rather than GetUniformSizesInBytes. static SizeT UniformStorageSpanInBytes(const glslang::TType* type, SizeT tightSize) { - if (type != nullptr && type->isMatrix() && type->getBasicType() != glslang::EbtDouble) { + if (type != nullptr && type->isMatrix()) { return static_cast(type->getMatrixCols()) * 4 * sizeof(Float); } + if (type != nullptr && type->getBasicType() == glslang::EbtDouble) { + return tightSize / 2; + } return tightSize; } SizeT GetUniformStorageSpanInBytes(Uint location) const { From 796a57a115f6a9b209f5101a1261f9fa8ef5e509 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 06:25:35 -0400 Subject: [PATCH 3/5] [Feat, Test] (MG_Backend, MG_Util, MG_Impl): report the fp64 tier at startup, decline 64-bit vertex formats everywhere, advertise GL_ARB_gpu_shader_fp64 only on request --- MobileGL/Config.h | 8 +++ MobileGL/ConfigLoader.cpp | 1 + .../DirectGLES/BackendObject_DirectGLES.cpp | 9 +++ .../BackendObject_DirectVulkan.cpp | 31 ++++++++- .../Scenarios/DoublePrecisionScenario.cpp | 39 +++++++++++ MobileGL/MG_Util/SelfTest/DriverPost.cpp | 67 ++++++++++++++----- 6 files changed, 139 insertions(+), 16 deletions(-) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index ca65bd67..93046299 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -82,6 +82,14 @@ namespace MobileGL::MG_Config { #endif // MOBILEGL_DISABLE_SUBGROUP: force-disable Vulkan shader subgroup support. Bool DisableSubgroup = false; + // MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension + // string. `double` in a shader always WORKS - it is narrowed to 32 bits before any + // module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension + // promises 64-bit precision, and that is the one thing the narrowing cannot deliver. + // Off by default so an application that checks the string before using doubles keeps + // its float path; on for measuring what the conformance suite makes of the demoted + // precision. See the DemoteFloat64Pass header and the "fp64" POST row. + Bool AdvertiseFp64 = false; // MOBILEGL_MAGMA_R11G11B10F_FALLBACK: use fallback format for R11G11B10F on Vulkan. Bool MagmaR11G11B10FFallback = false; // MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3. diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index b8f4b7dc..2322e8e4 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -167,6 +167,7 @@ namespace MobileGL::MG_ConfigLoader { QueryEnvVariable("MOBILEGL_TRACE_ANGLE_VARIANT", features.TraceAngleVariant, ""); #endif features.DisableSubgroup = QueryEnvFlag("MOBILEGL_DISABLE_SUBGROUP"); + features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64"); features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK"); features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); features.AvoidSamplerMipmapMinFilter = diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index ba0a75c4..42c0a4e4 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -960,6 +960,15 @@ namespace MobileGL::MG_Backend::DirectGLES { if (MG_Util::Async::AsyncShaderCompileEnabled()) { extensions.push_back(E_GL_KHR_parallel_shader_compile); } + // GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a + // shader compiles and runs already - it is narrowed to 32 bits before the module + // reaches this backend - so an application that simply uses doubles needs nothing + // advertised. What the extension additionally promises is 64-bit PRECISION, which no + // mobile GPU has and the narrowing cannot fake, so advertising it by default would + // make an application that checks the string take a path MobileGL cannot honour. + if (MG_Config::Features.AdvertiseFp64) { + extensions.push_back(E_GL_ARB_gpu_shader_fp64); + } // Only advertised when the device driver actually has usable timer queries // (GL_EXT_disjoint_timer_query plus its entry points) and the // MOBILEGL_DISABLE_TIMERQUERY escape hatch is off. diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 4a25b2aa..da2e639b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -539,6 +539,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (MG_Util::Async::AsyncShaderCompileEnabled()) { extensions.push_back(E_GL_KHR_parallel_shader_compile); } + // GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a + // shader compiles and runs already - it is narrowed to 32 bits before the module + // reaches this backend - so an application that simply uses doubles needs nothing + // advertised. What the extension additionally promises is 64-bit PRECISION, which no + // mobile GPU has and the narrowing cannot fake, so advertising it by default would + // make an application that checks the string take a path MobileGL cannot honour. + if (MG_Config::Features.AdvertiseFp64) { + extensions.push_back(E_GL_ARB_gpu_shader_fp64); + } // GL_ARB_timer_query gates MC's F3 GPU% (LWJGL checks the extension string); // only advertised when the device actually supports timestamp queries and the // MOBILEGL_DISABLE_TIMERQUERY escape hatch is off. @@ -877,7 +886,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray); } } - m_dynamicParameters.SupportsFloat64VertexAttributes = m_vulkanCaps.SupportsShaderFloat64; + // Never, on any device, and no longer for the reason it used to be. It used to track + // shaderFloat64 because a `dvec3` input needed the Float64 capability to exist in the + // module at all; a 64-bit vertex FETCH was already impossible (VK_FORMAT_R64*_SFLOAT is + // optional and lavapipe reports zero bufferFeatures for all four), so the attribute + // arrived as its 32-bit word pair and PackDoubleVertexInputsPass bitcast it back. + // + // The shader half of that is gone: every 64-bit float is narrowed before any module + // reaches a backend (ShaderTranspiler::DemoteFloat64Pass), so there is no `double` input + // left to bitcast INTO, and feeding a UINT-formatted attribute to what is now a `float` + // input would be silent garbage. Reconstructing the value would mean decoding the + // IEEE-754 double bit pattern in the shader - software fp64, which is precisely what the + // demotion exists to avoid - and on Espryt it would additionally need the ES driver to + // fetch 2N uint components where the application declared N doubles, which a dvec3 or + // dvec4 cannot even express within one attribute location. + // + // So glVertexAttribLFormat / glVertexAttribLPointer are declined here exactly as they + // already were on Espryt and on every real mobile device (Adreno and Mali both report + // shaderFloat64 == VK_FALSE), and for the same visible reason. A `dvec3` INPUT still + // compiles and draws - it is a `vec3` after demotion - as long as the application feeds + // it with glVertexAttribPointer(GL_FLOAT) rather than 64-bit data. + m_dynamicParameters.SupportsFloat64VertexAttributes = false; m_dynamicParameters.MaxShaderStorageBlockSize = std::min(m_vulkanCaps.MaxShaderStorageBlockSize, kMaxAdvertisedShaderStorageBlockSize); if (m_vulkanCaps.SupportsShaderSubgroup) { diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp index 51c37a61..fdac891c 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp @@ -314,5 +314,44 @@ void main() { EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); } + TEST_F(DoublePrecisionScenario, TheFp64ExtensionIsNotAdvertised) { + if (!Ready()) return; + // The shader above compiled, linked and ran without the extension string, which is + // the point: an application does not need GL_ARB_gpu_shader_fp64 advertised to USE + // doubles here. What the string additionally promises is 64-bit precision, and that + // is the one thing the demotion cannot deliver - so it stays off unless + // MOBILEGL_ADVERTISE_FP64 asks for it, and an application that branches on the + // string keeps taking its float path. + GLint extensionCount = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &extensionCount); + ASSERT_GT(extensionCount, 0); + bool advertised = false; + for (GLint i = 0; i < extensionCount; ++i) { + const char* name = reinterpret_cast(glGetStringi(GL_EXTENSIONS, static_cast(i))); + if (name != nullptr && std::string(name) == "GL_ARB_gpu_shader_fp64") advertised = true; + } + EXPECT_FALSE(advertised); + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + + TEST_F(DoublePrecisionScenario, A64BitVertexFormatIsDeclinedOnEveryBackend) { + if (!Ready()) return; + // The demotion leaves no 64-bit shader input to feed, so there is nothing a 64-bit + // vertex FETCH could be fetched into - on either backend, and no longer only on the + // ones whose device lacks shaderFloat64. Declined loudly rather than accepted and + // drawn as garbage; the matching POST row says the same thing at startup. + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + while (glGetError() != GL_NO_ERROR) {} + + glVertexAttribLFormat(0, 3, GL_DOUBLE, 0); + EXPECT_EQ(glGetError(), static_cast(GL_INVALID_OPERATION)); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + while (glGetError() != GL_NO_ERROR) {} + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 3f9ccba6..63ddf5c1 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -46,6 +46,22 @@ namespace MobileGL::MG_Util::SelfTest { RankMobileGLReported = 5, }; + // Both backends' fp64 rows end the same way, and the sentence they end with depends on + // a config flag rather than on anything either backend probes: the demotion is what + // makes doubles work, but GL_ARB_gpu_shader_fp64 promises the PRECISION the demotion + // cannot deliver, so the string is opt-in and the row has to say which way it went. + String AppendFp64AdvertisementNote(String detail) { + if (MG_Config::Features.AdvertiseFp64) { + return Move(detail) + + ". GL_ARB_gpu_shader_fp64 IS advertised (MOBILEGL_ADVERTISE_FP64): an application " + "that checks the string will believe it has 64-bit precision, and it does not"; + } + return Move(detail) + + ". GL_ARB_gpu_shader_fp64 is not advertised, because the precision it promises is the " + "one thing the demotion cannot provide; set MOBILEGL_ADVERTISE_FP64=1 to advertise it " + "anyway"; + } + struct ReportBuilder { BackendPostReport report; Bool fatalFailed = false; @@ -484,14 +500,24 @@ namespace MobileGL::MG_Util::SelfTest { break; } } - // Reported rather than probed: this one cannot come out any other way. OpenGL ES has no - // double-precision vertex format and ESSL has no fp64 type, so there is no driver and no - // extension that could make it work - the row exists so the loss is named at startup - // instead of discovered as an unexplained GL_INVALID_OPERATION at draw setup. + // Both rows are reported rather than probed: neither can come out any other way. + // ESSL has no 64-bit float type at all, so no driver and no extension could change + // either answer, and the rows exist so the two halves of the loss are named at + // startup instead of discovered as a shader that will not compile or an + // unexplained GL_INVALID_OPERATION at draw setup. + builder.Pass("fp64", AppendFp64AdvertisementNote( + "demoted to fp32 - ESSL has no 64-bit float type, so every double / " + "dvec / dmat in a shader is narrowed to 32 bits before transpilation " + "(DemoteFloat64Pass). Such shaders COMPILE AND RUN, at single " + "precision; a block containing a double is re-laid-out for the " + "narrowed members, so an application that hard-codes std140 offsets " + "computed for doubles must query them instead")); builder.Warn("64-bit vertex attributes", - "not supported on any GLES driver (ES has no GL_DOUBLE vertex format and ESSL has " - "no fp64 type); glVertexAttribLFormat / glVertexArrayAttribLFormat report " - "GL_INVALID_OPERATION - use the Vulkan backend if the application needs them"); + "not supported (ES has no GL_DOUBLE vertex format, and after the fp64 demotion " + "above there is no 64-bit shader input left to feed either); " + "glVertexAttribLFormat / glVertexArrayAttribLFormat report " + "GL_INVALID_OPERATION - feed the attribute with glVertexAttribPointer(GL_FLOAT), " + "which a demoted dvec input reads correctly"); if (glesFuncs.glPatchParameteri != nullptr) { builder.Pass("Tessellation patch parameters", "glPatchParameteri present (GL_PATCH_VERTICES reaches the driver)"); @@ -1843,14 +1869,25 @@ namespace MobileGL::MG_Util::SelfTest { "unsupported; a GL_TEXTURE_CUBE_MAP_ARRAY texture gets no image at all, so sampling " "one reads nothing and glFramebufferTextureLayer on one is declined"); } - if (features.shaderFloat64 == VK_TRUE) { - builder.Pass("shaderFloat64", - "GLSL double/dvec/dmat and 64-bit vertex attributes (glVertexAttribLFormat) supported"); - } else { - builder.Warn("shaderFloat64", - "unsupported; any shader declaring a double fails to create a shader module, and " - "glVertexAttribLFormat reports GL_INVALID_OPERATION instead of feeding the attribute"); - } + // Reported whichever way the device answers, because MobileGL no longer follows the + // device here: every 64-bit float is narrowed to 32 bits before any module reaches this + // backend (DemoteFloat64Pass), so the Float64 capability is never declared and a device + // that HAS the feature gains nothing from it. The device's own answer is still worth + // printing - it is the reason the demotion is unconditional. + builder.Pass("fp64", AppendFp64AdvertisementNote( + format("demoted to fp32 (device shaderFloat64 = {}) - every double / dvec / " + "dmat in a shader is narrowed to 32 bits before pipeline creation, so " + "such shaders BUILD AND RUN at single precision on every device " + "instead of failing to create a shader module on the ones without the " + "feature. A block containing a double is re-laid-out for the narrowed " + "members, so an application that hard-codes std140 offsets computed " + "for doubles must query them instead", + features.shaderFloat64 == VK_TRUE ? "supported" : "unsupported"))); + builder.Warn("64-bit vertex attributes", + "not supported; there is no 64-bit shader input left to feed after the fp64 demotion " + "above, and no VK_FORMAT_R64*_SFLOAT vertex fetch to feed it with on most devices " + "anyway. glVertexAttribLFormat reports GL_INVALID_OPERATION - feed the attribute with " + "glVertexAttribPointer(GL_FLOAT), which a demoted dvec input reads correctly"); Bool shaderDrawParameters = false; if (vkGetPhysicalDeviceFeatures2Fn != nullptr && properties.apiVersion >= VK_API_VERSION_1_1) { From 46fbd837b35aa540561b5fe5ee14dd15c28b73e0 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 06:41:54 -0400 Subject: [PATCH 4/5] [Fix, Test] (MG_State, MG_Impl, MG_IntegrationTest): a double uniform initializer no longer reads zero --- .../MG_Impl/GLImpl/Program/GL_Program.cpp | 7 +++- .../Scenarios/DoublePrecisionScenario.cpp | 39 +++++++++++++++++++ .../GLState/ProgramState/ProgramObject.cpp | 12 +++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index eeea664b..91da6f17 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -850,7 +850,12 @@ namespace MobileGL::MG_Impl::GLImpl { // vector per column - while the value glGetUniform* must return is tightly packed // columns * rows floats. Only mat4 is the same either way; every other shape needs the // padding undone, and the readback has to undo exactly what UniformMatrixfv_Object put - // there. Returns false when `ttype` is not a float matrix (nothing to unpack). + // there. Returns false when there is nothing here to unpack. + // + // A DOUBLE matrix is declined not because it is laid out differently - it is not, the + // demotion makes a dmat4 a mat4 in the shader and a mat4-shaped slot here - but because it + // is ROUTED differently: the caller's component-by-component EbtDouble branch has to widen + // each float back to the queried type, and it undoes the same padding itself. Bool TryGatherFloatMatrixColumns(const glslang::TType* ttype, const char* pBase, void* params) { if (ttype == nullptr || !ttype->isMatrix() || ttype->getBasicType() == glslang::EbtDouble) return false; const Int columns = ttype->getMatrixCols(); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp index fdac891c..cb782e31 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp @@ -314,6 +314,45 @@ void main() { EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); } + TEST_F(DoublePrecisionScenario, ADoubleUniformKeepsItsDeclaredInitializer) { + if (!Ready()) return; + // A declared initializer is seeded straight into the uniform shadow at link, and the + // seeding used to skip 64-bit floats outright ("no 32-bit shadow encoding") - which + // was true before the demotion and silently left every such uniform reading zero. + const char* source = R"(#version 430 core +layout(local_size_x = 1) in; +uniform double uSeeded = 2.5lf; +uniform dvec3 uSeededVector = dvec3(4.0lf, 5.0lf, 6.0lf); +layout(std430, binding = 0) buffer Output { + float g_out[]; +}; +void main() { + g_out[0] = float(uSeeded); + g_out[1] = float(uSeededVector.x); + g_out[2] = float(uSeededVector.y); + g_out[3] = float(uSeededVector.z); +} +)"; + const GLuint program = CompileComputeProgram(source); + ASSERT_NE(program, 0u) << m_buildLog; + + glUseProgram(program); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + std::vector values(4, -1.0f); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_output); + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, 4 * sizeof(float), values.data()); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); + glUseProgram(0); + glDeleteProgram(program); + + EXPECT_FLOAT_EQ(values[0], 2.5f) << "scalar double initializer"; + EXPECT_FLOAT_EQ(values[1], 4.0f) << "dvec3 initializer .x"; + EXPECT_FLOAT_EQ(values[2], 5.0f) << "dvec3 initializer .y"; + EXPECT_FLOAT_EQ(values[3], 6.0f) << "dvec3 initializer .z"; + EXPECT_EQ(glGetError(), static_cast(GL_NO_ERROR)); + } + TEST_F(DoublePrecisionScenario, TheFp64ExtensionIsNotAdvertised) { if (!Ready()) return; // The shader above compiled, linked and ran without the extension string, which is diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 41f81b6f..f2844f08 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -165,10 +165,18 @@ namespace MobileGL::MG_State::GLState { const Int elements = init.arraySize; if (componentsPerElement <= 0 || elements <= 0) continue; - const Bool isFloat = init.basicType == glslang::EbtFloat || init.basicType == glslang::EbtFloat16; + // EbtDouble belongs with the floats now, not with the skipped types: every 64-bit + // float in a shader is narrowed to 32 bits before the module reaches a backend + // (ShaderTranspiler::DemoteFloat64Pass), so a `uniform double d = 1.5;` has exactly + // the 32-bit shadow encoding a `uniform float` does - and glslang already folded its + // value into floatValues, which is a vector either way. Leaving it out meant + // the initializer was silently dropped and the uniform came up zero. + const Bool isFloat = init.basicType == glslang::EbtFloat || + init.basicType == glslang::EbtFloat16 || + init.basicType == glslang::EbtDouble; const Bool isInt = init.basicType == glslang::EbtInt || init.basicType == glslang::EbtUint || init.basicType == glslang::EbtBool; - // Anything else (fp64, 64-bit integers) has no 32-bit shadow encoding here, and a + // Anything else (64-bit integers) has no 32-bit shadow encoding here, and a // half-written uniform is worse than an untouched one. if (!isFloat && !isInt) continue; const SizeT provided = isFloat ? init.floatValues.size() : init.intValues.size(); From 96bd36c50be92d0944aa7485eb27b9186cd40f1c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 06:41:54 -0400 Subject: [PATCH 5/5] [Docs] (README): the MOBILEGL_ADVERTISE_FP64 switch --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 693615d4..d0e3b08d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ MobileGL supports runtime configuration via environment variables. | `MOBILEGL_DISABLE_TIMERQUERY` | Disable GPU timer-query exposure and use. | `0`, `1` | `0` | | `MOBILEGL_USE_ANGLE` | Load ANGLE EGL/GLES libraries. | `0`, `1` | `0` | | `MOBILEGL_DISABLE_SUBGROUP` | Disable Vulkan shader subgroup support. | `0`, `1` | `0` | +| `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1`–`64` | `3` | | `MOBILEGL_AVOID_SAMPLER_MIPMAP_MIN_FILTER` | Avoid sampler mipmap minification filters. | `0`, `1` | `0` |