[Fix, Test] (DirectGLES, ShaderTranspiler): emulate the 17 exactly-carriable non-core image formats by channel widening

This commit is contained in:
2026-08-21 03:21:09 -04:00
parent 36b9d26b9d
commit 3a12f6d4f3
16 changed files with 1551 additions and 29 deletions
@@ -34,6 +34,7 @@
#include "SpirvPasses/NormalizeRectCoordinatesPass.h"
#include "SpirvPasses/Lower1DArrayImagesPass.h"
#include "SpirvPasses/BakeImageFormatsPass.h"
#include "SpirvPasses/WidenImageFormatsPass.h"
#include "SpirvPasses/ClampMultisampleFetchPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
@@ -771,6 +772,35 @@ namespace MobileGL {
BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(glInternalFormat));
}
bool ShaderCompiler::WidenImageFormatsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(WidenImageFormatsPass::CreateWidenImageFormatsPass());
// Two image types that differed only in a format the widening collapses -
// `layout(rg32f)` and `layout(rgba32f)` in one module - are one type afterwards,
// and duplicate non-aggregate type declarations are invalid SPIR-V. This joins
// them, and cascades to the pointer and array types that named them; the pass
// itself deliberately does not carry a join of its own.
optimizer.RegisterPass(CreateRemoveDuplicatesPass());
return RunOptimizerChecked("WidenImageFormatsForEssl", optimizer, inputBinary, outputBinary,
true, enableSpirvValidation);
}
bool ShaderCompiler::DeclaresWidenableImageFormat(const Vector<Uint32>& binary) {
return WidenImageFormatsPass::DeclaresWidenableImageFormat(binary);
}
Uint ShaderCompiler::WidenedCoreEsslImageFormat(Uint glInternalFormat) {
return WidenImageFormatsPass::WidenedCoreEsslImageFormat(glInternalFormat);
}
Uint ShaderCompiler::ImageFormatChannelCount(Uint glInternalFormat) {
return WidenImageFormatsPass::ImageFormatChannelCount(glInternalFormat);
}
bool ShaderCompiler::FlattenXfbInterfaceBlocksForEssl(const Vector<Uint32>& inputBinary,
const std::set<String>& blockNames,
std::set<String>& flattenedBlockNames,
@@ -231,6 +231,31 @@ namespace MobileGL {
// must not ask BakeImageFormatsForEssl for those, and completes them in the
// emitted text instead.
static bool SpirvCrossCanPrintEsslImageFormat(Uint glInternalFormat);
// Re-declares every storage image whose DECLARED format GLSL ES cannot spell in
// the core format that carries it exactly, and masks each access back to the
// channels the original format has. The 26 formats outside the ES core set have no
// legal ESSL spelling on any tested driver (none exposes GL_NV_image_formats), and
// a format-less declaration is rejected too, so the stage is otherwise lost
// whatever this backend emits. DirectGLES transpile path only - Vulkan takes the
// declared format natively. See WidenImageFormatsPass for the table, for the nine
// formats it deliberately does NOT widen, and for why the texture storage and the
// glBindImageTexture argument have to move with it.
static bool WidenImageFormatsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Whether the module declares a storage image WidenImageFormatsForEssl would
// widen. One module parse, so the ~every shader that declares none pays no
// optimizer run.
static bool DeclaresWidenableImageFormat(const Vector<Uint32>& binary);
// The core-ESSL GL internal format that carries `glInternalFormat` exactly, or 0
// when it needs no widening or cannot be widened exactly. The single source of
// truth for all three layers of the emulation: this one answers the shader, and
// DirectGLES asks it again for the texture storage and the image bind, so the two
// sides cannot drift.
static Uint WidenedCoreEsslImageFormat(Uint glInternalFormat);
// Channels a GL image internal format really has (1-4), 0 when it is not one of
// the forty image formats.
static Uint ImageFormatChannelCount(Uint glInternalFormat);
static bool RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
@@ -8,6 +8,8 @@
#include "BakeImageFormatsPass.h"
#include "WidenImageFormatsPass.h"
#include "spirv.hpp"
#include "source/opt/build_module.h"
#include "source/opt/def_use_manager.h"
@@ -418,7 +420,18 @@ namespace MobileGL {
// Those formats are completed in the emitted text instead (see
// PrgramImpl::BakeImageFormatQualifiers); the module is left format-less for
// them, which is exactly the state that pass looks for.
if (!IsSpirvCrossEsslPrintableFormat(static_cast<Uint32>(format))) continue;
//
// UNLESS the format widens exactly: WidenImageFormatsPass runs immediately
// after this one on the ESSL chain and rewrites it to a core four-channel
// carrier SPIRV-Cross does print, masking the accesses back to the channels
// the baked format has. So for those the module IS the right place, and
// routing them to the text completion instead would spell the narrow format
// the driver rejects. The two lists are asked in this order because
// printability is the cheaper and more common answer.
if (!IsSpirvCrossEsslPrintableFormat(static_cast<Uint32>(format)) &&
WidenImageFormatsPass::WidenedCoreEsslImageFormat(formatIt->second) == 0) {
continue;
}
// spirv-val: "Expected Image Format to match Sampled Type". A bind format
// whose class disagrees with the declaration is an application error GL
// leaves undefined; baking it would turn that into an invalid module, so it
@@ -0,0 +1,540 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.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 "WidenImageFormatsPass.h"
#include "spirv.hpp"
#include "source/opt/build_module.h"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include <map>
#include <memory>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
// OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS,
// 5 Sampled, 6 Format.
constexpr uint32_t kImageSampledTypeOperand = 0;
constexpr uint32_t kImageSampledOperand = 5;
constexpr uint32_t kImageFormatOperand = 6;
// A storage image, i.e. one reached through imageLoad/imageStore rather than a
// sampler. The only kind that carries a format qualifier in any GLSL dialect.
constexpr uint32_t kSampledStorageImage = 2;
// OpImageRead in-operands: 0 image, 1 coordinate, 2.. optional image operands.
// OpImageWrite in-operands: 0 image, 1 coordinate, 2 texel, 3.. optional.
constexpr uint32_t kImageAccessImageOperand = 0;
constexpr uint32_t kImageWriteTexelOperand = 2;
// The exact carrier of a non-core image format: the core GLSL ES format with the
// SAME component type and the SAME per-channel width, differing only in channel
// count. `channels` is what the original format really has, which is what every
// access through the carrier is masked back to.
//
// Only formats that widen EXACTLY appear here. r11f_g11f_b10f, rgb10_a2,
// rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm and r16_snorm have no
// same-width core carrier - every candidate is either lossy or changes the numeric
// domain a sampler would read - and are deliberately absent, so they keep the
// honest "no GLSL ES spelling" diagnostic rather than a silent approximation.
struct ImageFormatWidening {
spv::ImageFormat Carrier = spv::ImageFormat::Unknown;
uint32_t Channels = 0;
explicit operator bool() const { return Carrier != spv::ImageFormat::Unknown; }
};
ImageFormatWidening WideningOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
// Float.
case spv::ImageFormat::Rg32f: return {spv::ImageFormat::Rgba32f, 2};
case spv::ImageFormat::Rg16f: return {spv::ImageFormat::Rgba16f, 2};
case spv::ImageFormat::R16f: return {spv::ImageFormat::Rgba16f, 1};
// Unsigned normalized.
case spv::ImageFormat::Rg8: return {spv::ImageFormat::Rgba8, 2};
case spv::ImageFormat::R8: return {spv::ImageFormat::Rgba8, 1};
// Signed normalized.
case spv::ImageFormat::Rg8Snorm: return {spv::ImageFormat::Rgba8Snorm, 2};
case spv::ImageFormat::R8Snorm: return {spv::ImageFormat::Rgba8Snorm, 1};
// Signed integer.
case spv::ImageFormat::Rg32i: return {spv::ImageFormat::Rgba32i, 2};
case spv::ImageFormat::Rg16i: return {spv::ImageFormat::Rgba16i, 2};
case spv::ImageFormat::R16i: return {spv::ImageFormat::Rgba16i, 1};
case spv::ImageFormat::Rg8i: return {spv::ImageFormat::Rgba8i, 2};
case spv::ImageFormat::R8i: return {spv::ImageFormat::Rgba8i, 1};
// Unsigned integer.
case spv::ImageFormat::Rg32ui: return {spv::ImageFormat::Rgba32ui, 2};
case spv::ImageFormat::Rg16ui: return {spv::ImageFormat::Rgba16ui, 2};
case spv::ImageFormat::R16ui: return {spv::ImageFormat::Rgba16ui, 1};
case spv::ImageFormat::Rg8ui: return {spv::ImageFormat::Rgba8ui, 2};
case spv::ImageFormat::R8ui: return {spv::ImageFormat::Rgba8ui, 1};
default:
return {};
}
}
// The GL 4.2 image format table (core spec table 8.26) as SPIR-V ImageFormats.
// Written as literals rather than through the GL headers because this lives in
// MG_Util, which the GL frontend's enums do not reach; the same list, in the same
// order, as BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat.
spv::ImageFormat SpirvImageFormatOfGL(Uint glInternalFormat) {
switch (glInternalFormat) {
case 0x8814: /*GL_RGBA32F*/ return spv::ImageFormat::Rgba32f;
case 0x881A: /*GL_RGBA16F*/ return spv::ImageFormat::Rgba16f;
case 0x8230: /*GL_RG32F*/ return spv::ImageFormat::Rg32f;
case 0x822F: /*GL_RG16F*/ return spv::ImageFormat::Rg16f;
case 0x8C3A: /*GL_R11F_G11F_B10F*/ return spv::ImageFormat::R11fG11fB10f;
case 0x822E: /*GL_R32F*/ return spv::ImageFormat::R32f;
case 0x822D: /*GL_R16F*/ return spv::ImageFormat::R16f;
case 0x8D70: /*GL_RGBA32UI*/ return spv::ImageFormat::Rgba32ui;
case 0x8D76: /*GL_RGBA16UI*/ return spv::ImageFormat::Rgba16ui;
case 0x8D7C: /*GL_RGBA8UI*/ return spv::ImageFormat::Rgba8ui;
case 0x906F: /*GL_RGB10_A2UI*/ return spv::ImageFormat::Rgb10a2ui;
case 0x823C: /*GL_RG32UI*/ return spv::ImageFormat::Rg32ui;
case 0x823A: /*GL_RG16UI*/ return spv::ImageFormat::Rg16ui;
case 0x8238: /*GL_RG8UI*/ return spv::ImageFormat::Rg8ui;
case 0x8236: /*GL_R32UI*/ return spv::ImageFormat::R32ui;
case 0x8234: /*GL_R16UI*/ return spv::ImageFormat::R16ui;
case 0x8232: /*GL_R8UI*/ return spv::ImageFormat::R8ui;
case 0x8D82: /*GL_RGBA32I*/ return spv::ImageFormat::Rgba32i;
case 0x8D88: /*GL_RGBA16I*/ return spv::ImageFormat::Rgba16i;
case 0x8D8E: /*GL_RGBA8I*/ return spv::ImageFormat::Rgba8i;
case 0x823B: /*GL_RG32I*/ return spv::ImageFormat::Rg32i;
case 0x8239: /*GL_RG16I*/ return spv::ImageFormat::Rg16i;
case 0x8237: /*GL_RG8I*/ return spv::ImageFormat::Rg8i;
case 0x8235: /*GL_R32I*/ return spv::ImageFormat::R32i;
case 0x8233: /*GL_R16I*/ return spv::ImageFormat::R16i;
case 0x8231: /*GL_R8I*/ return spv::ImageFormat::R8i;
case 0x8058: /*GL_RGBA8*/ return spv::ImageFormat::Rgba8;
case 0x805B: /*GL_RGBA16*/ return spv::ImageFormat::Rgba16;
case 0x8059: /*GL_RGB10_A2*/ return spv::ImageFormat::Rgb10A2;
case 0x822B: /*GL_RG8*/ return spv::ImageFormat::Rg8;
case 0x822C: /*GL_RG16*/ return spv::ImageFormat::Rg16;
case 0x8229: /*GL_R8*/ return spv::ImageFormat::R8;
case 0x822A: /*GL_R16*/ return spv::ImageFormat::R16;
case 0x8F97: /*GL_RGBA8_SNORM*/ return spv::ImageFormat::Rgba8Snorm;
case 0x8F9B: /*GL_RGBA16_SNORM*/ return spv::ImageFormat::Rgba16Snorm;
case 0x8F95: /*GL_RG8_SNORM*/ return spv::ImageFormat::Rg8Snorm;
case 0x8F99: /*GL_RG16_SNORM*/ return spv::ImageFormat::Rg16Snorm;
case 0x8F94: /*GL_R8_SNORM*/ return spv::ImageFormat::R8Snorm;
case 0x8F98: /*GL_R16_SNORM*/ return spv::ImageFormat::R16Snorm;
default:
return spv::ImageFormat::Unknown;
}
}
Uint GLInternalFormatOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
case spv::ImageFormat::Rgba32f: return 0x8814; // GL_RGBA32F
case spv::ImageFormat::Rgba16f: return 0x881A; // GL_RGBA16F
case spv::ImageFormat::Rgba8: return 0x8058; // GL_RGBA8
case spv::ImageFormat::Rgba8Snorm: return 0x8F97; // GL_RGBA8_SNORM
case spv::ImageFormat::Rgba32i: return 0x8D82; // GL_RGBA32I
case spv::ImageFormat::Rgba16i: return 0x8D88; // GL_RGBA16I
case spv::ImageFormat::Rgba8i: return 0x8D8E; // GL_RGBA8I
case spv::ImageFormat::Rgba32ui: return 0x8D70; // GL_RGBA32UI
case spv::ImageFormat::Rgba16ui: return 0x8D76; // GL_RGBA16UI
case spv::ImageFormat::Rgba8ui: return 0x8D7C; // GL_RGBA8UI
default:
// Only the carriers need the reverse direction, and every carrier is one
// of the four-channel core formats above.
return 0;
}
}
uint32_t ChannelsOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
case spv::ImageFormat::R32f:
case spv::ImageFormat::R16f:
case spv::ImageFormat::R16:
case spv::ImageFormat::R8:
case spv::ImageFormat::R16Snorm:
case spv::ImageFormat::R8Snorm:
case spv::ImageFormat::R32i:
case spv::ImageFormat::R16i:
case spv::ImageFormat::R8i:
case spv::ImageFormat::R32ui:
case spv::ImageFormat::R16ui:
case spv::ImageFormat::R8ui:
return 1;
case spv::ImageFormat::Rg32f:
case spv::ImageFormat::Rg16f:
case spv::ImageFormat::Rg16:
case spv::ImageFormat::Rg8:
case spv::ImageFormat::Rg16Snorm:
case spv::ImageFormat::Rg8Snorm:
case spv::ImageFormat::Rg32i:
case spv::ImageFormat::Rg16i:
case spv::ImageFormat::Rg8i:
case spv::ImageFormat::Rg32ui:
case spv::ImageFormat::Rg16ui:
case spv::ImageFormat::Rg8ui:
return 2;
case spv::ImageFormat::R11fG11fB10f:
return 3;
case spv::ImageFormat::Rgba32f:
case spv::ImageFormat::Rgba16f:
case spv::ImageFormat::Rgba16:
case spv::ImageFormat::Rgb10A2:
case spv::ImageFormat::Rgba8:
case spv::ImageFormat::Rgba16Snorm:
case spv::ImageFormat::Rgba8Snorm:
case spv::ImageFormat::Rgba32i:
case spv::ImageFormat::Rgba16i:
case spv::ImageFormat::Rgba8i:
case spv::ImageFormat::Rgba32ui:
case spv::ImageFormat::Rgba16ui:
case spv::ImageFormat::Rgba8ui:
case spv::ImageFormat::Rgb10a2ui:
return 4;
default:
return 0;
}
}
Bool IsWidenableStorageImageType(const Instruction* type) {
if (type == nullptr || type->opcode() != spv::Op::OpTypeImage) return false;
if (type->GetSingleWordInOperand(kImageSampledOperand) != kSampledStorageImage) return false;
const auto format =
static_cast<spv::ImageFormat>(type->GetSingleWordInOperand(kImageFormatOperand));
return static_cast<Bool>(WideningOfSpirvImageFormat(format));
}
} // namespace
Uint WidenImageFormatsPass::WidenedCoreEsslImageFormat(Uint glInternalFormat) {
const ImageFormatWidening widening =
WideningOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat));
if (!widening) return 0;
return GLInternalFormatOfSpirvImageFormat(widening.Carrier);
}
Uint WidenImageFormatsPass::ImageFormatChannelCount(Uint glInternalFormat) {
return ChannelsOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat));
}
bool WidenImageFormatsPass::DeclaresWidenableImageFormat(const Vector<Uint32>& binary) {
std::unique_ptr<IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, [](spv_message_level_t, const char*, const spv_position_t&, const char*) {},
binary.data(), binary.size());
if (!context) {
return false;
}
for (const Instruction& type : context->module()->types_values()) {
if (IsWidenableStorageImageType(&type)) {
return true;
}
}
return false;
}
spvtools::opt::Pass::Status WidenImageFormatsPass::Process() {
auto* irContext = context();
auto* defUseMgr = irContext->get_def_use_mgr();
// Cheap gate first: no widenable image type, and the module is handed back
// byte-identical - which is every shader but a handful.
std::vector<Instruction*> imageTypes;
for (Instruction& type : irContext->types_values()) {
if (IsWidenableStorageImageType(&type)) {
imageTypes.push_back(&type);
}
}
if (imageTypes.empty()) {
return Status::SuccessWithoutChange;
}
// What each widenable image type becomes, and the mask its accesses take. Keyed on
// the type's result id so the access walk below can ask about an image VALUE by
// its type without re-deriving anything.
struct WidenedImage {
spv::ImageFormat Carrier = spv::ImageFormat::Unknown;
uint32_t Channels = 0;
uint32_t SampledTypeId = 0;
};
std::map<uint32_t, WidenedImage> widenedByTypeId;
for (Instruction* type : imageTypes) {
const auto format =
static_cast<spv::ImageFormat>(type->GetSingleWordInOperand(kImageFormatOperand));
const ImageFormatWidening widening = WideningOfSpirvImageFormat(format);
widenedByTypeId.emplace(type->result_id(),
WidenedImage{widening.Carrier, widening.Channels,
type->GetSingleWordInOperand(kImageSampledTypeOperand)});
}
// Collect the accesses BEFORE anything is mutated, and refuse the whole rewrite if
// any of them is a shape this pass cannot mask end to end. A widened declaration
// whose accesses were left unmasked is worse than the compile error it replaced:
// the shader runs and quietly reads the carrier's surplus channels, which GL says
// are 0 and 1. Refusing hands the stage back to the "no GLSL ES spelling"
// diagnostic instead, which at least names the failure.
std::vector<Instruction*> reads;
std::vector<Instruction*> writes;
Bool rewritable = true;
for (auto funcIt = irContext->module()->begin();
funcIt != irContext->module()->end() && rewritable; ++funcIt) {
funcIt->ForEachInst([&](Instruction* inst) {
if (!rewritable) return;
switch (inst->opcode()) {
case spv::Op::OpImageRead:
case spv::Op::OpImageWrite:
case spv::Op::OpImageSparseRead:
case spv::Op::OpImageTexelPointer:
break;
default:
return;
}
// OpImageTexelPointer names the image VARIABLE (a pointer), the other
// three an image VALUE; both reach the OpTypeImage through the def's
// type, one hop further for the pointer.
const Instruction* imageDef =
defUseMgr->GetDef(inst->GetSingleWordInOperand(kImageAccessImageOperand));
if (imageDef == nullptr) return;
uint32_t imageTypeId = imageDef->type_id();
if (const Instruction* imageType = defUseMgr->GetDef(imageTypeId);
imageType != nullptr && imageType->opcode() == spv::Op::OpTypePointer) {
imageTypeId = imageType->GetSingleWordInOperand(1);
}
const auto widenedIt = widenedByTypeId.find(imageTypeId);
if (widenedIt == widenedByTypeId.end()) return;
if (inst->opcode() == spv::Op::OpImageRead) {
reads.push_back(inst);
return;
}
if (inst->opcode() == spv::Op::OpImageWrite) {
writes.push_back(inst);
return;
}
// OpImageSparseRead yields a struct rather than a plain texel vector, and
// OpImageTexelPointer is an image atomic - which spirv-val already
// restricts to r32i/r32ui/r32f, all three of them core formats that never
// reach this table. Neither is expressible in the ESSL this backend emits,
// so rather than mask a shape that has never been seen, decline.
rewritable = false;
});
}
if (!rewritable) {
return Status::SuccessWithoutChange;
}
// The four-component (0, .., 0, 1) constant each mask shuffles its surplus
// channels out of, one per component type in play. GL defines an imageLoad from a
// format with fewer than four channels as (r, 0, 0, 1) and an imageStore as
// dropping the components the format does not have, so pinning the carrier's
// surplus channels to exactly these values is the whole of the emulation.
std::map<uint32_t, uint32_t> zeroOneConstantBySampledType; // sampled type id -> constant id
std::map<uint32_t, uint32_t> vec4TypeBySampledType; // sampled type id -> v4 type id
auto resolveMaskMaterial = [&](uint32_t sampledTypeId, uint32_t& outConstantId,
uint32_t& outVec4TypeId) -> Bool {
if (const auto cached = zeroOneConstantBySampledType.find(sampledTypeId);
cached != zeroOneConstantBySampledType.end()) {
outConstantId = cached->second;
outVec4TypeId = vec4TypeBySampledType[sampledTypeId];
return outConstantId != 0 && outVec4TypeId != 0;
}
const Instruction* sampledType = defUseMgr->GetDef(sampledTypeId);
if (sampledType == nullptr) return false;
uint32_t oneWord = 0;
std::unique_ptr<analysis::Type> component;
if (sampledType->opcode() == spv::Op::OpTypeFloat &&
sampledType->GetSingleWordInOperand(0) == 32) {
component = spvtools::MakeUnique<analysis::Float>(32);
oneWord = 0x3F800000u; // 1.0f
} else if (sampledType->opcode() == spv::Op::OpTypeInt &&
sampledType->GetSingleWordInOperand(0) == 32) {
// OpTypeInt in-operands: 0 width, 1 signedness.
component = spvtools::MakeUnique<analysis::Integer>(
32, sampledType->GetSingleWordInOperand(1) != 0);
oneWord = 1u;
} else {
return false;
}
auto* typeMgr = irContext->get_type_mgr();
auto* constantMgr = irContext->get_constant_mgr();
analysis::Type* componentReg = typeMgr->GetRegisteredType(component.get());
if (componentReg == nullptr) return false;
const analysis::Constant* zero = constantMgr->GetConstant(componentReg, {0u});
const analysis::Constant* one = constantMgr->GetConstant(componentReg, {oneWord});
if (zero == nullptr || one == nullptr) return false;
const Instruction* zeroInst = constantMgr->GetDefiningInstruction(zero);
const Instruction* oneInst = constantMgr->GetDefiningInstruction(one);
if (zeroInst == nullptr || oneInst == nullptr) return false;
analysis::Vector vector(componentReg, 4);
const uint32_t vec4TypeId = typeMgr->GetTypeInstruction(&vector);
if (vec4TypeId == 0) return false;
// Through the id rather than through GetRegisteredType(&vector): the
// instruction the line above declared (or found) is the one the constant has
// to be typed by, and asking the manager for its type is what guarantees the
// two are the same registered object.
analysis::Type* vectorReg = typeMgr->GetType(vec4TypeId);
if (vectorReg == nullptr) return false;
// A vector constant's "literal words" are the IDS of its components
// (ConstantManager::CreateConstant -> GetConstantsFromIds).
const analysis::Constant* zeroOne = constantMgr->GetConstant(
vectorReg, {zeroInst->result_id(), zeroInst->result_id(), zeroInst->result_id(),
oneInst->result_id()});
if (zeroOne == nullptr) return false;
const Instruction* zeroOneInst = constantMgr->GetDefiningInstruction(zeroOne);
if (zeroOneInst == nullptr) return false;
outConstantId = zeroOneInst->result_id();
outVec4TypeId = vec4TypeId;
zeroOneConstantBySampledType.emplace(sampledTypeId, outConstantId);
vec4TypeBySampledType.emplace(sampledTypeId, outVec4TypeId);
return true;
};
// OpVectorShuffle selects components 0-3 from the first vector and 4-7 from the
// second, so with (0, 0, 0, 1) as the second operand the mask for a `channels`-
// channel format is [0 .. channels-1] followed by 4 + i for the rest: the surplus
// channels take the constant's 0s and, at index 3, its 1.
auto maskComponents = [](uint32_t channels) {
std::vector<Operand> components;
components.reserve(4);
for (uint32_t i = 0; i < 4; ++i) {
components.push_back(
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {i < channels ? i : 4u + i}});
}
return components;
};
auto widenedOf = [&](const Instruction* inst) -> const WidenedImage* {
const Instruction* imageDef =
defUseMgr->GetDef(inst->GetSingleWordInOperand(kImageAccessImageOperand));
if (imageDef == nullptr) return nullptr;
const auto it = widenedByTypeId.find(imageDef->type_id());
return it == widenedByTypeId.end() ? nullptr : &it->second;
};
// Every constant and vector type the masks will need, declared BEFORE the first
// instruction is inserted. The constant and type managers append to the module's
// globals and keep their own def-use bookkeeping straight; the shuffles below do
// not (this pass invalidates every analysis at the end instead), so doing the two
// in the other order would have the managers consult a def-use map that no longer
// describes the function bodies.
for (const auto& widened : widenedByTypeId) {
uint32_t unusedConstantId = 0;
uint32_t unusedVec4TypeId = 0;
if (!resolveMaskMaterial(widened.second.SampledTypeId, unusedConstantId, unusedVec4TypeId)) {
return Status::SuccessWithoutChange;
}
}
// Masks first, while every image type still carries its ORIGINAL format: the
// rewrite below only touches the format operand, so the accesses' types do not
// move and the order is free either way - but doing it first keeps a failed
// resolve from leaving a half-widened module behind.
for (Instruction* write : writes) {
const WidenedImage* widened = widenedOf(write);
if (widened == nullptr) continue;
uint32_t zeroOneId = 0;
uint32_t vec4TypeId = 0;
if (!resolveMaskMaterial(widened->SampledTypeId, zeroOneId, vec4TypeId)) {
return Status::SuccessWithoutChange;
}
const uint32_t texelId = write->GetSingleWordInOperand(kImageWriteTexelOperand);
const Instruction* texel = defUseMgr->GetDef(texelId);
// SPIR-V allows a scalar texel; GLSL's imageStore always passes a gvec4, and a
// shape this has never seen is refused rather than guessed at.
if (texel == nullptr || texel->type_id() != vec4TypeId) {
return Status::SuccessWithoutChange;
}
const uint32_t maskedId = irContext->TakeNextId();
if (maskedId == 0) return Status::Failure;
Instruction::OperandList shuffleOperands{{SPV_OPERAND_TYPE_ID, {texelId}},
{SPV_OPERAND_TYPE_ID, {zeroOneId}}};
for (const Operand& component : maskComponents(widened->Channels)) {
shuffleOperands.push_back(component);
}
write->InsertBefore(spvtools::MakeUnique<Instruction>(
irContext, spv::Op::OpVectorShuffle, vec4TypeId, maskedId, shuffleOperands));
write->SetInOperand(kImageWriteTexelOperand, {maskedId});
}
for (Instruction* read : reads) {
const WidenedImage* widened = widenedOf(read);
if (widened == nullptr) continue;
uint32_t zeroOneId = 0;
uint32_t vec4TypeId = 0;
if (!resolveMaskMaterial(widened->SampledTypeId, zeroOneId, vec4TypeId)) {
return Status::SuccessWithoutChange;
}
if (read->type_id() != vec4TypeId) {
return Status::SuccessWithoutChange;
}
// The ORIGINAL instruction keeps its result id and becomes the shuffle, and a
// copy of the read is inserted in front of it under a fresh id. That way every
// existing use of the read stays intact without a ReplaceAllUsesWith that
// would also rewrite the shuffle's own operand (the idiom
// EmulateNoPerspectivePass uses for the same reason).
const uint32_t rawReadId = irContext->TakeNextId();
if (rawReadId == 0) return Status::Failure;
Instruction::OperandList readOperands;
for (uint32_t i = 0; i < read->NumInOperands(); ++i) {
readOperands.push_back(read->GetInOperand(i));
}
read->InsertBefore(spvtools::MakeUnique<Instruction>(
irContext, spv::Op::OpImageRead, vec4TypeId, rawReadId, readOperands));
read->SetOpcode(spv::Op::OpVectorShuffle);
Instruction::OperandList shuffleOperands{{SPV_OPERAND_TYPE_ID, {rawReadId}},
{SPV_OPERAND_TYPE_ID, {zeroOneId}}};
for (const Operand& component : maskComponents(widened->Channels)) {
shuffleOperands.push_back(component);
}
read->SetInOperands(Move(shuffleOperands));
}
// The declaration itself, last. Only the format operand moves: the carrier has the
// same component type as the original by construction, so the OpTypeImage's
// Sampled Type still agrees with it (which is what spirv-val checks) and no
// pointer, array or access-chain type has to be rebuilt.
//
// Two image types can COLLIDE here - `layout(rg32f)` and `layout(rgba32f)` in one
// module both become Rgba32f - and duplicate non-aggregate type declarations are
// invalid SPIR-V. The caller runs spirv-tools' RemoveDuplicates pass immediately
// after this one, which joins them (and cascades to the pointer and array types
// that named them) rather than this pass carrying its own join.
for (Instruction* type : imageTypes) {
const auto widenedIt = widenedByTypeId.find(type->result_id());
if (widenedIt == widenedByTypeId.end()) continue;
type->SetInOperand(kImageFormatOperand, {static_cast<uint32_t>(widenedIt->second.Carrier)});
defUseMgr->AnalyzeInstUse(type);
}
// StorageImageExtendedFormats is deliberately left declared even though every
// remaining format is now one of the thirteen that need no capability: a
// capability a module no longer exercises is valid SPIR-V, and dropping one is
// only safe after proving no extended format is left ANYWHERE, including in image
// types this pass declined.
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken WidenImageFormatsPass::CreateWidenImageFormatsPass() {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<WidenImageFormatsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,101 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.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 "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Emulates the desktop-GL image formats GLSL ES cannot spell by CHANNEL WIDENING: a
// storage image DECLARED `layout(rg32f)` is re-declared `layout(rgba32f)` and every
// access through it is masked back to the two channels GL says it has.
//
// WHY IT IS NEEDED AT ALL. GL 4.2 has forty image formats; GLSL ES 3.1 has thirteen,
// and GL_NV_image_formats - the only extension that adds the rest - is advertised by
// none of Adreno 830, Mali-G1-Ultra MC12 or Mali-G925-Immortalis MC12 (probed on all
// three, with `#extension ... : enable` also rejected, so "the driver implements it
// unadvertised" is refuted rather than assumed). A shader that declares one of the
// other twenty-six therefore has NO legal ESSL spelling, and it fails in one of two
// ways: SPIRV-Cross throws for its is_desktop_only_format set and no text is produced
// at all, or the token reaches the driver and is rejected ("'rg32f' : not a legal
// layout qualifier id"). Either way the stage is lost, the backend program is
// unusable, and every draw with it silently renders nothing while the frontend keeps
// reporting GL_LINK_STATUS = TRUE. Dropping the qualifier instead is not an escape:
// all three drivers reject a format-LESS image declaration outright ("all images have
// to define layout format" / "S0001: Image must specify a format layout qualifier"),
// readonly and writeonly alike, at both #version 310 es and 320 es. And unlike a
// numeric limit there is nothing honest to report either - GL has no "this image
// format is unsupported" query - so the format has to be emulated.
//
// WHAT WIDENING MEANS. Seventeen of the twenty-six have a core ESSL format of the
// SAME PER-CHANNEL WIDTH AND COMPONENT TYPE, differing only in channel count
// (rg32f -> rgba32f, r8ui -> rgba8ui, rg8_snorm -> rgba8_snorm, ...). Carried in one
// of those the emulation is EXACT, not approximate: every value is representable bit
// for bit, and GL's own image semantics do the rest -
//
// * imageLoad on a format with fewer than four channels returns (r, 0, 0, 1);
// * imageStore drops the components the format does not have.
//
// so the two surplus channels of the carrier are not free storage, they are values GL
// already defines. This pass pins them: every OpImageWrite through a widened image has
// its texel replaced by (r[, g[, b]], 0.., 1) and every OpImageRead has its result
// masked the same way. Masking BOTH is deliberate belt and braces - the write mask
// alone keeps the storage canonical for a sampler and for glGetTexImage, the read mask
// alone survives storage this shader never wrote (glTexStorage with no upload, whose
// surplus channels are undefined).
//
// The other NINE (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16, r16,
// rgba16_snorm, rg16_snorm, r16_snorm) have NO same-width core carrier and are
// deliberately NOT widened here: every carrier for them is either lossy or changes the
// numeric domain of the texture a `sampler2D` would read from it. They keep the honest
// "no GLSL ES spelling" diagnostic instead of silently changing an application's
// quantisation behaviour.
//
// MUST MOVE WITH THE OTHER TWO LAYERS. The widening is not a shader-local rewrite: the
// ES texture behind the image has to be allocated in the carrier format too, and
// glBindImageTexture has to be handed the carrier (on Adreno the bind of the narrow
// format is GL_INVALID_VALUE for nineteen of the twenty-six, and on both Malis for
// twenty-five). Both are done in DirectGLES against the same table below, so the two
// sides agree by construction rather than by convention. Binding a narrow texture
// through a wide image is NOT an option: every tested driver accepts it silently, so
// it reads and writes out of bounds undetected.
//
// ESSL ONLY. DirectVulkan takes the declared format natively and resolves the view
// format from the same bind state, so the module must reach it unchanged.
class WidenImageFormatsPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-widen-image-formats"; }
Status Process() override;
// Whether the module declares a storage image whose format this pass would widen,
// i.e. whether running it could change anything. Answered from a single parse so
// the caller can skip the optimizer run entirely - which is every shader but a
// handful.
static bool DeclaresWidenableImageFormat(const Vector<Uint32>& binary);
// The core-ESSL GL internal format that carries `glInternalFormat` exactly, or 0
// when the format needs no widening (it is core already) or cannot be widened
// exactly (the nine above, and anything that is not an image format at all).
// Used by DirectGLES for the texture storage and the glBindImageTexture argument,
// so that all three layers pick the same carrier.
static Uint WidenedCoreEsslImageFormat(Uint glInternalFormat);
// Channels the GL internal format really has (1-4), or 0 when it is not one of the
// forty image formats. The count the widened accesses are masked back to.
static Uint ImageFormatChannelCount(Uint glInternalFormat);
static spvtools::Optimizer::PassToken CreateWidenImageFormatsPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -162,6 +162,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.Value(static_cast<Uint32>(inputs.shaderType));
builder.Value(static_cast<Uint8>(inputs.supportsViewportArray));
builder.Value(static_cast<Uint8>(inputs.supportsNoperspectiveInterpolation));
builder.Value(static_cast<Uint8>(inputs.supportsExtendedImageFormats));
builder.Value(inputs.maxColorTextureSamples);
builder.Value(inputs.maxIntegerSamples);
builder.Value(inputs.maxDepthTextureSamples);
@@ -602,6 +602,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// --- driver capability bits that arm or steer a pass ---
Bool supportsViewportArray = false;
Bool supportsNoperspectiveInterpolation = false;
// GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage
// image whose format GLSL ES core cannot spell in the core format that carries it and
// masks its accesses back - so a driver that HAS the extension and one that does not get
// materially different ESSL from the same module.
Bool supportsExtendedImageFormats = false;
Int32 maxColorTextureSamples = 0;
Int32 maxIntegerSamples = 0;
Int32 maxDepthTextureSamples = 0;