[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
+1
View File
@@ -294,6 +294,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
+28 -1
View File
@@ -1463,8 +1463,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Bool layerable = SupportsLayeredImageBinding(imageBinding.Texture->GetTarget());
const GLboolean layered = layerable ? imageBinding.Layered : GL_FALSE;
const GLint layer = layerable ? imageBinding.Layer : 0;
// The bind half of the image-format widening. SyncTextureObjectToBackend has just
// allocated this texture's storage in the core carrier of its format (the call above
// is the one that marks it image-bindable), and glBindImageTexture's `format` has to
// name the storage the texture really has: a GL_RG32F bind is GL_INVALID_VALUE on
// Adreno for nineteen of the twenty-six non-core formats and on both Malis for
// twenty-five, and every driver that DOES accept a narrow texture through a wide
// image accepts it silently, reading and writing out of bounds. The frontend's own
// ImageTextureBinding keeps the application's format untouched, so
// GL_IMAGE_BINDING_FORMAT still answers what was passed in.
//
// Widened from the format the APPLICATION named rather than from the texture's own,
// because GL lets the two differ inside one format class and the shader was widened
// from the class the application named too (an r32ui view of an r32f image is a legal
// reinterpretation). The two carriers always have the same texel size - every format
// in a class widens to the four-channel form of that same class - so the storage
// still describes what the bind claims. Gated on the TEXTURE having been widened, so
// a bind format that names a class the storage does not have is left alone: GL
// already calls that undefined, and inventing a carrier for it would only make the
// out-of-class read wider.
GLenum bindFormat = imageBinding.Format;
if (TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) {
const auto boundFormatWidening = TextureImpl::GetImageBindableStorageWidening(
MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format));
if (boundFormatWidening) {
bindFormat = boundFormatWidening.InternalFormat;
}
}
g_GLESFuncs.glBindImageTexture(unit, backendTexture->GetBackendTextureId(), imageBinding.Level,
layered, layer, imageBinding.Access, imageBinding.Format);
layered, layer, imageBinding.Access, bindFormat);
}
// A buffer texture bound to a WRITABLE image unit is a buffer the shader is about to
+232 -27
View File
@@ -2282,6 +2282,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
m_imageBindableStorageRequired = true;
m_isInitialized = false;
// The storage this re-mints may also be CHANNEL WIDENED (a GL_RG32F image is not
// bindable on this driver at all, so it becomes a GL_RGBA32F carrying two channels),
// and a widened texture's sampled view has to answer the channels the logical format
// does not have with 0 and 1 - which is a swizzle. The parameter sync is gated on the
// frontend's params version, which this transition does not move, so without the
// override an application that never touched GL_TEXTURE_SWIZZLE_* would keep the
// driver at its defaults and sample the carrier's surplus channels raw.
m_forceTextureParamsResync = true;
}
void BackendTextureObject::RecreateBackendTexture() {
@@ -2579,7 +2587,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<Uint8>& widenedData, Bool integerData) {
Uint8 oneBits[8] = {};
SizeT componentSize = 0;
if (componentCount != 3 || data == nullptr || byteSize == 0 ||
// One and two source components as well as three: the image-format widening carries
// GL_R8UI in a GL_RGBA8UI and GL_RG32F in a GL_RGBA32F (see
// TextureImpl::GetImageBindableStorageWidening), and their surplus channels take the
// same values the three-channel case gives its single added one - zeroes, and the
// format's implied 1 in alpha.
if (componentCount == 0 || componentCount > 3 || data == nullptr || byteSize == 0 ||
!GetUploadComponentOneBits(uploadType, integerData, oneBits, &componentSize)) {
return data;
}
@@ -2606,7 +2619,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Memcpy(dst, src, srcTexelBytes);
src += srcTexelBytes;
}
Memcpy(dst + srcTexelBytes, oneBits, componentSize);
// ALWAYS at component 3, never at `componentCount`: GL's implied 1 is the ALPHA
// channel, and a one- or two-component source leaves the channels between it and
// alpha at the zero `assign` already wrote. For three components the two
// expressions coincide, which is what this used to be written as.
Memcpy(dst + componentSize * 3, oneBits, componentSize);
}
return widenedData.data();
}
@@ -2689,6 +2706,42 @@ namespace MobileGL::MG_Backend::DirectGLES {
widenedData, IsIntegerWidenableFormat(format));
}
// The transfer half of the image-format widening: an image-bindable texture whose ES
// storage was widened to a core carrier is described to the driver as a four-component
// transfer, so its one- or two-component client data has to be repacked the same way the
// three-channel colour-renderable widening repacks its own.
//
// Composes with PrepareFallbackUpload rather than replacing it, and the composition is a
// no-op by construction: none of the seventeen widened formats is a three-channel one
// (GetWidenableClientComponentCount reports 0 for every one of them), and the SNORM
// shadow-to-float conversion only fires for a GL_FLOAT transfer type, which the widened
// triple never picks for the two SNORM8 formats. So the shadow reaches this untouched and
// one repack is all that runs.
static const void* PrepareImageWidenedUpload(const TextureImpl::ImageBindableStorageWidening& widening,
const IntVec3& texelSize, const void* data, SizeT byteSize,
Vector<Uint8>& widenedData) {
if (!widening || widening.SourceChannels == 0 || widening.SourceChannels >= 4) {
return data;
}
return PrepareChannelWidenedUpload(widening.SourceChannels, texelSize, data, byteSize, widening.Type,
widenedData, widening.IntegerData);
}
// Overwrites the (internal format, format, type) triple GenerateTextureFormatInfo chose
// with the widened carrier's. Deliberately unconditional on anything but the widening
// itself: whatever renderability fallback the triple carried, an image the driver refuses
// to bind is useless, so the image constraint wins.
static void ApplyImageBindableStorageWidening(const TextureImpl::ImageBindableStorageWidening& widening,
GLenum* inOutInternalFormat, GLenum* inOutFormat,
GLenum* inOutType) {
if (!widening) {
return;
}
if (inOutInternalFormat) *inOutInternalFormat = widening.InternalFormat;
if (inOutFormat) *inOutFormat = widening.Format;
if (inOutType) *inOutType = widening.Type;
}
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
@@ -2884,6 +2937,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bind(target);
}
// Only a texture that is actually image-bound pays for the widening: it doubles
// or quadruples the storage, and RequireImageBindableStorage is sticky, so a
// texture that is merely sampled keeps its narrow format for life. See
// TextureImpl::GetImageBindableStorageWidening for what widens and why.
const TextureImpl::ImageBindableStorageWidening imageWidening =
m_imageBindableStorageRequired
? TextureImpl::GetImageBindableStorageWidening(textureMipmapObject->GetFormat())
: TextureImpl::ImageBindableStorageWidening{};
const Bool canAppendMipmaps =
m_isInitialized &&
!m_imageBindableStorageRequired &&
@@ -2989,6 +3051,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum glInternalFormat, glType, glFormat;
TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat,
&glFormat, &glType, targetInternal);
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
const auto& uploadTargets = textureMipmapObject->GetUploadTargets();
if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) {
@@ -3104,6 +3167,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
Vector<Uint8> imageWidenedUploadData;
uploadData = PrepareImageWidenedUpload(imageWidening, levelTexelSize, uploadData,
levelByteSize, imageWidenedUploadData);
DebugImpl::ErrorLopper::Clear();
BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned
@@ -3243,6 +3309,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum glInternalFormat, glType, glFormat;
TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat,
&glFormat, &glType, targetInternal);
// The storage this level is being written into was widened when it was minted
// (see above), so the transfer pair has to describe the carrier here too - ES
// requires glTexSubImage's `format` to match the storage's base internal
// format, so a GL_RG upload into a GL_RGBA32F image is GL_INVALID_OPERATION.
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
const auto& uploadTargets = textureMipmapObject->GetUploadTargets();
ScopedDefaultUnpackState unpackState;
for (auto& uploadTarget : uploadTargets) {
@@ -3281,6 +3352,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData);
// Leaves `uploadData` pointing at its own buffer when it fires, which
// is exactly what takes the sub-rect fast path below out of play: that
// path strides into the SHADOW, and the widened texels are four
// components wide where the shadow's are one or two.
Vector<Uint8> imageWidenedUploadData;
uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize,
imageWidenedUploadData);
const IntVec3 uploadSize =
GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize);
// Sub-rect upload: when only a region of the level changed (a
@@ -3726,6 +3804,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
}
// The same composition for the image-format widening, which can add TWO or THREE
// channels rather than one (GL_R8UI carried in a GL_RGBA8UI). GL reads a channel the
// format does not have as 0, except alpha, which reads as 1 - so a sampler must see
// those constants and not whatever the widened storage holds. The upload and the
// shader's own store mask already keep them at exactly these values; this covers
// storage nothing has written yet (glTexStorage with no upload), whose surplus
// channels are undefined. Composed with the application's own swizzle for the same
// reason as the alpha case above: GL_TEXTURE_SWIZZLE names a SOURCE channel of the
// logical texel, so it is the source that is substituted, never the destination.
if (const auto imageWidening =
m_imageBindableStorageRequired
? TextureImpl::GetImageBindableStorageWidening(stateTextureObject->GetFormat())
: TextureImpl::ImageBindableStorageWidening{}) {
for (SizeT channel = 0; channel < 4; ++channel) {
switch (swizzleParams[channel]) {
case TextureSwizzleParam::Green:
if (imageWidening.SourceChannels < 2) swizzleParams[channel] = TextureSwizzleParam::Zero;
break;
case TextureSwizzleParam::Blue:
if (imageWidening.SourceChannels < 3) swizzleParams[channel] = TextureSwizzleParam::Zero;
break;
case TextureSwizzleParam::Alpha:
if (imageWidening.SourceChannels < 4) swizzleParams[channel] = TextureSwizzleParam::One;
break;
default:
break;
}
}
}
if (swizzleParams != m_cacheSwizzleParams) {
#define SYNC_TEX_SWIZZLE_PARAM_IF_CHANGED(func, glEnum) \
if (m_cacheSwizzleParams.func != swizzleParams.func) { \
@@ -5042,6 +5149,45 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false;
}
}
// The GL internal format a glslang layout format names, for the seventeen non-core
// formats WidenImageFormatsForEssl carries exactly plus nothing else: the only
// question asked of it is "does this DECLARED format widen", and answering 0 for
// everything else is the same "no" a non-widenable format gets. Kept as its own
// switch rather than routed through the frontend's enum converters because a
// TLayoutFormat is a glslang value and the reflection snapshot stores it raw.
Uint GLInternalFormatOfLayoutFormat(glslang::TLayoutFormat format) {
switch (format) {
case glslang::ElfRg32f: return 0x8230; // GL_RG32F
case glslang::ElfRg16f: return 0x822F; // GL_RG16F
case glslang::ElfR16f: return 0x822D; // GL_R16F
case glslang::ElfRg8: return 0x822B; // GL_RG8
case glslang::ElfR8: return 0x8229; // GL_R8
case glslang::ElfRg8Snorm: return 0x8F95; // GL_RG8_SNORM
case glslang::ElfR8Snorm: return 0x8F94; // GL_R8_SNORM
case glslang::ElfRg32i: return 0x823B; // GL_RG32I
case glslang::ElfRg16i: return 0x8239; // GL_RG16I
case glslang::ElfR16i: return 0x8233; // GL_R16I
case glslang::ElfRg8i: return 0x8237; // GL_RG8I
case glslang::ElfR8i: return 0x8231; // GL_R8I
case glslang::ElfRg32ui: return 0x823C; // GL_RG32UI
case glslang::ElfRg16ui: return 0x823A; // GL_RG16UI
case glslang::ElfR16ui: return 0x8234; // GL_R16UI
case glslang::ElfRg8ui: return 0x8238; // GL_RG8UI
case glslang::ElfR8ui: return 0x8232; // GL_R8UI
default:
return 0;
}
}
// Whether the ESSL chain will re-declare an image of this format in a core carrier
// and mask its accesses (WidenImageFormatsForEssl). Armed only where there is no
// GL_NV_image_formats to spell the format natively - a driver that has the extension
// keeps the declaration and gets the `#extension` directive instead.
Bool ImageFormatWillBeWidened(Uint glInternalFormat) {
return !g_GLESCapabilities.SupportsExtendedImageFormats && glInternalFormat != 0 &&
MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(glInternalFormat) != 0;
}
} // namespace
// What the format bake needs from the frontend, collected in one walk of the uniform
@@ -5074,17 +5220,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!IsImageUniformType(stateProgramObject.GetUniformType(loc))) continue;
const auto& type = stateProgramObject.GetUniformTypeFacts(loc);
if (type.hasFormat) {
// Declared, and therefore left exactly as written - but a non-core spelling
// still needs the extension directive to survive the ES compiler.
if (!IsCoreEsslLayoutFormat(static_cast<glslang::TLayoutFormat>(type.layoutFormat))) {
inputs.needsExtendedImageFormats = true;
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
// From the OWNED TypeFacts, not from a live TType: the reflection
// snapshot already carries the declared layout format, and there is
// no glslang object to ask on a translation-cache L1 hit.
recordUnspellableFormat(
name, glslang::TQualifier::getLayoutFormatString(
static_cast<glslang::TLayoutFormat>(type.layoutFormat)));
// Declared, and therefore never overridden by the BAKE - but a non-core
// spelling still has to become legal ESSL somehow.
const auto declaredFormat = static_cast<glslang::TLayoutFormat>(type.layoutFormat);
if (!IsCoreEsslLayoutFormat(declaredFormat)) {
// Seventeen of the twenty-six non-core formats are re-declared in the core
// format that carries them exactly, with every access masked back to the
// channels GL says they have (WidenImageFormatsForEssl, and the matching
// storage/bind widening in TextureImpl). Those need neither the extension
// nor the diagnostic: there IS a legal spelling for them now.
if (!ImageFormatWillBeWidened(GLInternalFormatOfLayoutFormat(declaredFormat))) {
inputs.needsExtendedImageFormats = true;
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
// From the OWNED TypeFacts, not from a live TType: the reflection
// snapshot already carries the declared layout format, and there
// is no glslang object to ask on a translation-cache L1 hit.
recordUnspellableFormat(
name, glslang::TQualifier::getLayoutFormatString(declaredFormat));
}
}
}
continue;
@@ -5102,20 +5255,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (boundFormat == 0) continue;
if (!MG_Util::ShaderTranspiler::ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(boundFormat)) {
// Outside the GLSL ES core set, so the emitted ESSL only compiles with
// GL_NV_image_formats. Without the extension there is no legal spelling at
// all, and baking one would trade a "no format qualifier" compile error for
// an "unsupported format" one - so the image is left format-less. Its unit
// stays in the key, so a rebind to a core format still rebuilds and works.
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
MGLOG_D("Image uniform '%s' has no declared format and its unit %d holds 0x%x, which GLSL ES "
"core cannot spell and this driver has no GL_NV_image_formats for.",
name.c_str(), unit, boundFormat);
recordUnspellableFormat(
name, MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(boundFormat));
continue;
// Outside the GLSL ES core set and with no GL_NV_image_formats to spell
// it. If the format widens exactly it is still baked HERE, narrow, and
// WidenImageFormatsForEssl re-declares it in its core carrier immediately
// afterwards - both routes into that pass end in the same place.
//
// If it does not widen there is no legal ESSL for this stage at all.
// Leaving the image format-LESS is NOT a softer failure: all three test
// devices reject a format-less image declaration outright ("all images
// have to define layout format"), readonly and writeonly alike, so it
// trades one hard compile error for another. The unit stays in the
// rebuild key either way, so a rebind to a spellable format still
// rebuilds and works.
if (!ImageFormatWillBeWidened(boundFormat)) {
MGLOG_D("Image uniform '%s' has no declared format and its unit %d holds 0x%x, which "
"GLSL ES core cannot spell, this driver has no GL_NV_image_formats for, and "
"no core format carries exactly.",
name.c_str(), unit, boundFormat);
recordUnspellableFormat(
name, MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(boundFormat));
continue;
}
} else {
inputs.needsExtendedImageFormats = true;
}
inputs.needsExtendedImageFormats = true;
}
const String baseName = ImageUniformBaseName(name);
const auto existing = inputs.glFormatByUniformName.find(baseName);
@@ -5147,6 +5311,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(entry.second)) {
continue;
}
// A format the widening carries stays on the module route even though SPIRV-Cross
// would not print it: by the time SPIRV-Cross sees the declaration it names the
// core carrier, which it does print. Writing the narrow spelling into the text
// instead would put back exactly the token the driver rejects.
if (ImageFormatWillBeWidened(entry.second)) {
continue;
}
String spelling = MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(entry.second);
if (spelling.empty()) continue; // no image-format spelling at all; nothing to write
inputs.esslFormatQualifierByUniformName.emplace(entry.first, Move(spelling));
@@ -5197,8 +5368,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// Reads (audited): the arguments; g_GLESCapabilities.{SupportsViewportArray,
// MaxSamples, MaxColorTextureSamples, MaxIntegerSamples, MaxDepthTextureSamples,
// SupportsNoperspectiveInterpolation, GLESVersion} (the last via
// ResolveBackendEsslVersion); and m_backendProgramId, for a log line only.
// SupportsNoperspectiveInterpolation, SupportsExtendedImageFormats, GLESVersion}
// (the last via ResolveBackendEsslVersion); and m_backendProgramId, for a log line only.
//
// Deliberately NOT in here, and therefore NOT in the key: the text-level passes that
// follow in SyncToBackend. They are cheap string work and they read a long tail of
@@ -5445,6 +5616,38 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &imageFormatSpirv;
}
// GL has forty image formats and GLSL ES core has thirteen; the other twenty-seven
// reach ES only through GL_NV_image_formats, which no tested driver advertises. A
// shader declaring one of them has NO legal ESSL spelling at all - SPIRV-Cross throws
// for some of them and the driver rejects the token for the rest ("'rg32f' : not a
// legal layout qualifier id"), and dropping the qualifier is refused too ("all images
// have to define layout format") - so the stage is lost and every draw with the
// program silently renders nothing. Seventeen of them widen EXACTLY into a core format
// of the same per-channel width, and this rewrites those declarations to the carrier
// and masks every access back to the channels GL says the format has. The other nine
// have no exact carrier and keep the honest diagnostic
// CollectImageFormatBakeInputs emits.
//
// AFTER the bake above, deliberately: a format-less image whose unit holds a non-core
// format is baked with that format and widened here, so both routes end in the same
// place and there is no second widening rule for baked declarations.
//
// KEY MATERIAL: g_GLESCapabilities.SupportsExtendedImageFormats, which ARMS this -
// see EsslTranslationKeyInputs::supportsExtendedImageFormats. The pass takes no other
// input: what it rewrites is a pure function of the module's own declared formats,
// and the module is already the largest thing in the L2 key.
//
// DirectVulkan is deliberately not given this: it takes the declared format natively
// and resolves the descriptor's view format from the same bind state.
Vector<unsigned int> widenedImageFormatSpirv;
if (!g_GLESCapabilities.SupportsExtendedImageFormats &&
MG_Util::ShaderTranspiler::ShaderCompiler::DeclaresWidenableImageFormat(*effectiveSpirv) &&
MG_Util::ShaderTranspiler::ShaderCompiler::WidenImageFormatsForEssl(
*effectiveSpirv, widenedImageFormatSpirv, enableSpirvValidation) &&
!widenedImageFormatSpirv.empty()) {
effectiveSpirv = &widenedImageFormatSpirv;
}
// GLSL ES demands a constant integral expression to index a fragment output
// array; SPIR-V does not, so a shader that writes coeff[i] from a loop
// reaches SPIRV-Cross intact and comes out as ESSL a strict driver rejects
@@ -5781,6 +5984,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
esslKeyInputs.supportsViewportArray = g_GLESCapabilities.SupportsViewportArray;
esslKeyInputs.supportsNoperspectiveInterpolation =
g_GLESCapabilities.SupportsNoperspectiveInterpolation;
esslKeyInputs.supportsExtendedImageFormats =
g_GLESCapabilities.SupportsExtendedImageFormats;
esslKeyInputs.maxColorTextureSamples = g_GLESCapabilities.MaxColorTextureSamples;
esslKeyInputs.maxIntegerSamples = g_GLESCapabilities.MaxIntegerSamples;
esslKeyInputs.maxDepthTextureSamples = g_GLESCapabilities.MaxDepthTextureSamples;
+38
View File
@@ -12,6 +12,7 @@
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -233,6 +234,43 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
}
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) {
// Same arming as WidenImageFormatsForEssl. A driver with GL_NV_image_formats spells
// the narrow format natively, and widening the storage behind a shader that still
// says `rg32f` would make the two disagree about the texel size.
if (g_GLESCapabilities.SupportsExtendedImageFormats) {
return {};
}
const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const auto carrier = static_cast<GLenum>(
MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(requested));
if (carrier == 0) {
return {};
}
ImageBindableStorageWidening widening;
widening.InternalFormat = carrier;
widening.SourceChannels =
MG_Util::ShaderTranspiler::ShaderCompiler::ImageFormatChannelCount(requested);
switch (carrier) {
case GL_RGBA32UI:
case GL_RGBA16UI:
case GL_RGBA8UI:
case GL_RGBA32I:
case GL_RGBA16I:
case GL_RGBA8I:
widening.IntegerData = true;
break;
default:
widening.IntegerData = false;
break;
}
// The carrier is a core ES format in every case, so it needs no fallback options of
// its own; this call is only here to spell the transfer pair that describes it.
MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags<PixelFormatNormalizeOptionBit>{},
nullptr, &widening.Format, &widening.Type);
return widening;
}
} // namespace TextureImpl
namespace PrgramImpl {
String ProcessOutColorLocations(const String& glslCode) {
+57
View File
@@ -60,6 +60,63 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
// The CHANNEL WIDENING an image-bindable texture's ES storage takes, so that a format
// GLSL ES cannot spell as an image is carried by one it can.
//
// GL has forty image formats, GLSL ES core has thirteen, and no test device advertises
// GL_NV_image_formats - so a shader declaring one of the other twenty-six has no legal
// ESSL at all and glBindImageTexture rejects the narrow format outright for most of them
// (GL_INVALID_VALUE for nineteen of twenty-six on Adreno, twenty-five on both Malis).
// Seventeen have a core format of the SAME per-channel width and component type,
// differing only in channel count, and in one of those the emulation is EXACT: GL already
// defines an imageLoad from a narrower format as (r, 0, 0, 1) and an imageStore as
// dropping the components the format does not have, so the carrier's surplus channels
// hold values GL has already named. WidenImageFormatsPass pins them in the shader; this
// is the storage half, and DirectGLES::TextureImpl::SyncImageTextureBinding the bind
// half. All three ask WidenedCoreEsslImageFormat, so they cannot pick different carriers.
//
// Reports nothing (InternalFormat == GL_UNKNOWN_MGL) for a format that is core already,
// for the nine with no exact carrier (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16,
// r16, rgba16_snorm, rg16_snorm, r16_snorm - those keep the honest "no GLSL ES spelling"
// diagnostic rather than a silent approximation), and on a driver that HAS
// GL_NV_image_formats, where the shader keeps the declared format and no widening may
// happen behind it.
//
// The widened triple REPLACES what GenerateTextureFormatInfo chose, including any
// renderability substitution: an image that cannot be image-bound is useless whatever its
// attachment behaviour, so the image constraint wins. In practice that only bites
// RG8_SNORM/R8_SNORM on a driver without EXT_render_snorm, where the storage stays
// signed-normalized instead of becoming the half float that fallback would have picked -
// so an image-bound texture in one of those two formats is no longer attachable, and
// glGetTexImage on it falls through to the CPU shadow, which a shader-side imageStore
// does not update. Accepted deliberately: before the widening, an image binding in either
// format was refused outright by every driver tested and the stage that declared it never
// compiled at all, so nothing that works today is being given up.
//
// KNOWN GAP, for the same "all three layers move together" reason: a widened texture that
// is ALSO an FBO colour attachment gains one to three writable channels, and a draw into
// it can leave values in channels GL says are 0 and 1. Sampling and imageLoad are covered
// (the swizzle composition in SyncTextureParamsToBackend and the shader-side mask), but a
// glReadPixels/glGetTexImage that asks for more channels than the frontend format has
// would see them. Closing it needs the per-draw-buffer colour mask the three-channel
// widening already carries (FramebufferImpl::g_alphaWidenedDrawBufferMask) generalized
// from "alpha" to a channel count, which is its own change.
struct ImageBindableStorageWidening {
GLenum InternalFormat = GL_UNKNOWN_MGL;
GLenum Format = GL_UNKNOWN_MGL;
GLenum Type = GL_UNKNOWN_MGL;
// Channels the FRONTEND format has, i.e. how many of the carrier's four the client
// data fills. The rest are uploaded as 0, and the fourth as the format's implied 1.
Uint SourceChannels = 0;
// Whether that implied 1 is the integer one or a saturated normalized field - the
// transfer type cannot tell the two apart (GL_UNSIGNED_BYTE serves both RG8 and
// RG8UI), so the carrier decides.
Bool IntegerData = false;
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat);
} // namespace TextureImpl
namespace FramebufferImpl {} // namespace FramebufferImpl
@@ -14,6 +14,7 @@ add_executable(
ClampMultisampleFetchTest.cpp
LegalizeStorageBlockArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -863,6 +863,13 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
v.supportsNoperspectiveInterpolation = true;
variants.emplace_back("supportsNoperspectiveInterpolation", BuildEsslTranslationKey(v));
}
{ // arms WidenImageFormatsForEssl - a driver WITH GL_NV_image_formats keeps the declared
// rg32f/r8ui/... image formats, one without has them re-declared in a core carrier and
// every access to them masked, so the two get materially different ESSL from one module.
EsslTranslationKeyInputs v = base;
v.supportsExtendedImageFormats = true;
variants.emplace_back("supportsExtendedImageFormats", BuildEsslTranslationKey(v));
}
{ // arms AND parameterizes ClampMultisampleFetchesForEssl
EsslTranslationKeyInputs v = base;
v.maxColorTextureSamples = 1;
@@ -0,0 +1,397 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.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
//
// WidenImageFormatsPass exists because GL has forty image formats and GLSL ES core has thirteen,
// and no device MobileGL runs on advertises GL_NV_image_formats - so a shader declaring one of the
// other twenty-six has no legal ESSL spelling at all. SPIRV-Cross throws for some of them and the
// driver rejects the token for the rest ("'rg32f' : not a legal layout qualifier id"), and dropping
// the qualifier is refused too ("all images have to define layout format"), so the stage is lost
// and every draw with the program silently renders nothing while GL_LINK_STATUS still says TRUE.
//
// What has to hold is the emulation's exactness, in three parts at once: the DECLARED format must
// become the core carrier of the same per-channel width, every imageStore through it must have its
// surplus components replaced by GL's own (0.., 1) so the carrier's extra channels never hold
// anything GL has not defined, and every imageLoad must come back masked the same way. A module
// that declares only core formats - or one of the nine formats with no exact carrier - must come
// out untouched, because widening those would be an approximation rather than an emulation. Real
// GLSL through the same glslang path the backends use, for the same reason
// ClampMultisampleFetchTest.cpp does it: what matters is what glslang actually emits.
#include <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <string>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileFragment(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .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 = {GL_FRAGMENT_SHADER},
.program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
// OpTypeImage words: 0 opcode/count, 1 result id, 2 sampled type, 3 Dim, 4 Depth, 5 Arrayed,
// 6 MS, 7 Sampled, 8 Format. Sampled == 2 is a storage image, the only kind with a format.
struct StorageImageType {
Uint32 resultId = 0u;
Uint32 format = 0u;
};
Vector<StorageImageType> CollectStorageImageTypes(const Vector<Uint32>& spirv) {
Vector<StorageImageType> types;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpTypeImage || wordCount < 9u) return;
if (words[7] != 2u) return;
types.push_back(StorageImageType{words[1], words[8]});
});
return types;
}
// OpVectorShuffle words: 0 opcode/count, 1 result type, 2 result id, 3 vector 1, 4 vector 2,
// 5.. the component selectors.
struct VectorShuffle {
Uint32 resultId = 0u;
Uint32 firstVectorId = 0u;
Uint32 secondVectorId = 0u;
Vector<Uint32> components;
};
Vector<VectorShuffle> CollectVectorShuffles(const Vector<Uint32>& spirv) {
Vector<VectorShuffle> shuffles;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpVectorShuffle || wordCount < 5u) return;
VectorShuffle shuffle{};
shuffle.resultId = words[2];
shuffle.firstVectorId = words[3];
shuffle.secondVectorId = words[4];
for (Uint32 word = 5u; word < wordCount; ++word) {
shuffle.components.push_back(words[word]);
}
shuffles.push_back(shuffle);
});
return shuffles;
}
// OpImageWrite words: 0 opcode/count, 1 image, 2 coordinate, 3 texel.
Vector<Uint32> CollectImageWriteTexelIds(const Vector<Uint32>& spirv) {
Vector<Uint32> texels;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpImageWrite || wordCount < 4u) return;
texels.push_back(words[3]);
});
return texels;
}
// OpImageRead words: 0 opcode/count, 1 result type, 2 result id, 3 image, 4 coordinate.
Vector<Uint32> CollectImageReadResultIds(const Vector<Uint32>& spirv) {
Vector<Uint32> results;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpImageRead || wordCount < 5u) return;
results.push_back(words[2]);
});
return results;
}
Bool HasComponents(const VectorShuffle& shuffle, const Vector<Uint32>& expected) {
return shuffle.components == expected;
}
const VectorShuffle* FindShuffleWithResult(const Vector<VectorShuffle>& shuffles, Uint32 resultId) {
for (const VectorShuffle& shuffle : shuffles) {
if (shuffle.resultId == resultId) return &shuffle;
}
return nullptr;
}
const VectorShuffle* FindShuffleOver(const Vector<VectorShuffle>& shuffles, Uint32 firstVectorId) {
for (const VectorShuffle& shuffle : shuffles) {
if (shuffle.firstVectorId == firstVectorId) return &shuffle;
}
return nullptr;
}
// rg32f: two float channels, and the entry the four CTS allFormats walkers abort on. Both an
// imageLoad and an imageStore, so both masks are exercised on one image.
const char* const kRg32fLoadStore = R"(#version 430 core
layout(rg32f, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// r8ui: ONE unsigned-integer channel, and the only format
// KHR-GL43.shader_image_load_store.single-byte_data_alignment declares. SPIRV-Cross refuses to
// print this one for ESSL at all, so before the widening the stage produced no text whatsoever.
const char* const kR8uiLoadStore = R"(#version 430 core
layout(r8ui, binding = 0) uniform uimage2D img;
out vec4 fragColor;
void main() {
uvec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), uvec4(7u, 8u, 9u, 10u));
fragColor = vec4(texel);
}
)";
// rgba32f is one of the thirteen GLSL ES already has; nothing may move.
const char* const kCoreFormatLoadStore = R"(#version 430 core
layout(rgba32f, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// rg16 is one of the NINE with no core carrier of the same per-channel width. Widening it
// would change the quantisation an application sees, so it must be left alone and keep the
// honest "no GLSL ES spelling" diagnostic instead.
const char* const kRg16LoadStore = R"(#version 430 core
layout(rg16, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
} // namespace
// The table itself, which is the single source of truth all three layers of the emulation ask -
// the shader rewrite, the ES texture storage and the glBindImageTexture argument. If it drifts
// the three stop agreeing, and a narrow texture read through a wide image goes out of bounds
// silently on every driver tested.
TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) {
struct Case {
Uint requested;
Uint carrier;
Uint channels;
const char* name;
};
const Case cases[] = {
{0x8230, 0x8814, 2, "GL_RG32F -> GL_RGBA32F"},
{0x822F, 0x881A, 2, "GL_RG16F -> GL_RGBA16F"},
{0x822D, 0x881A, 1, "GL_R16F -> GL_RGBA16F"},
{0x822B, 0x8058, 2, "GL_RG8 -> GL_RGBA8"},
{0x8229, 0x8058, 1, "GL_R8 -> GL_RGBA8"},
{0x8F95, 0x8F97, 2, "GL_RG8_SNORM -> GL_RGBA8_SNORM"},
{0x8F94, 0x8F97, 1, "GL_R8_SNORM -> GL_RGBA8_SNORM"},
{0x823B, 0x8D82, 2, "GL_RG32I -> GL_RGBA32I"},
{0x8239, 0x8D88, 2, "GL_RG16I -> GL_RGBA16I"},
{0x8233, 0x8D88, 1, "GL_R16I -> GL_RGBA16I"},
{0x8237, 0x8D8E, 2, "GL_RG8I -> GL_RGBA8I"},
{0x8231, 0x8D8E, 1, "GL_R8I -> GL_RGBA8I"},
{0x823C, 0x8D70, 2, "GL_RG32UI -> GL_RGBA32UI"},
{0x823A, 0x8D76, 2, "GL_RG16UI -> GL_RGBA16UI"},
{0x8234, 0x8D76, 1, "GL_R16UI -> GL_RGBA16UI"},
{0x8238, 0x8D7C, 2, "GL_RG8UI -> GL_RGBA8UI"},
{0x8232, 0x8D7C, 1, "GL_R8UI -> GL_RGBA8UI"},
};
for (const Case& testCase : cases) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(testCase.requested), testCase.carrier)
<< testCase.name;
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(testCase.requested), testCase.channels)
<< testCase.name;
// Every carrier is one of the thirteen ES has in core, or the widening would have moved
// the problem rather than solved it - and every carrier has four channels, or the mask
// selectors would address components that are not there.
EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(testCase.carrier))
<< testCase.name;
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(testCase.carrier), 4u) << testCase.name;
}
}
TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) {
// The thirteen GLSL ES already has: nothing to carry.
for (const Uint coreFormat : {0x8814u /*RGBA32F*/, 0x881Au /*RGBA16F*/, 0x822Eu /*R32F*/,
0x8058u /*RGBA8*/, 0x8F97u /*RGBA8_SNORM*/, 0x8D82u /*RGBA32I*/,
0x8D88u /*RGBA16I*/, 0x8D8Eu /*RGBA8I*/, 0x8235u /*R32I*/,
0x8D70u /*RGBA32UI*/, 0x8D76u /*RGBA16UI*/, 0x8D7Cu /*RGBA8UI*/,
0x8236u /*R32UI*/}) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(coreFormat), 0u)
<< "core format 0x" << std::hex << coreFormat;
}
// The nine with no core format of the same per-channel width. Carrying these would be an
// approximation - a different quantisation, or a different numeric domain for anything that
// samples the same texture - so they are deliberately left to the honest diagnostic.
for (const Uint hardFormat : {0x8C3Au /*R11F_G11F_B10F*/, 0x8059u /*RGB10_A2*/,
0x906Fu /*RGB10_A2UI*/, 0x805Bu /*RGBA16*/, 0x822Cu /*RG16*/,
0x822Au /*R16*/, 0x8F9Bu /*RGBA16_SNORM*/, 0x8F99u /*RG16_SNORM*/,
0x8F98u /*R16_SNORM*/}) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(hardFormat), 0u)
<< "format without an exact carrier 0x" << std::hex << hardFormat;
}
// Not an image format at all.
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(0x8051 /*GL_RGB8*/), 0u);
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(0x8051 /*GL_RGB8*/), 0u);
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(0), 0u);
}
TEST(WidenImageFormats, TwoChannelFloatImageBecomesRgba32fWithBothAccessesMasked) {
const Vector<Uint32> spirv = CompileFragment(kRg32fLoadStore);
ASSERT_FALSE(spirv.empty());
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
// ...and there is nothing left for a second run to do.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(widened));
const auto beforeTypes = CollectStorageImageTypes(spirv);
ASSERT_EQ(beforeTypes.size(), 1u);
EXPECT_EQ(beforeTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg32f));
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba32f));
const auto shuffles = CollectVectorShuffles(widened);
// The STORE. GL drops the components a two-channel format does not have, so the carrier's
// blue and alpha must be written as its own 0 and 1, never as what the shader passed.
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr) << "the imageStore texel is not a masked value";
EXPECT_TRUE(HasComponents(*storeMask, {0u, 1u, 6u, 7u}))
<< "expected (r, g, 0, 1) - components 0 and 1 of the texel, then 2 and 3 of (0,0,0,1)";
// The LOAD. Same mask, on the other side: GL defines an imageLoad from a two-channel format
// as (r, g, 0, 1) whatever the storage holds, which matters for storage this shader never
// wrote (glTexStorage with no upload leaves the surplus channels undefined).
const auto readIds = CollectImageReadResultIds(widened);
ASSERT_EQ(readIds.size(), 1u);
const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front());
ASSERT_NE(loadMask, nullptr) << "the imageLoad result is consumed unmasked";
EXPECT_TRUE(HasComponents(*loadMask, {0u, 1u, 6u, 7u}));
EXPECT_NE(loadMask->resultId, readIds.front())
<< "the mask must be a separate value, or it would feed itself";
}
TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccessesMasked) {
const Vector<Uint32> spirv = CompileFragment(kR8uiLoadStore);
ASSERT_FALSE(spirv.empty());
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba8ui));
const auto shuffles = CollectVectorShuffles(widened);
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr);
// Only red survives; green and blue take the constant's zeroes and alpha its one - the
// INTEGER one, not a saturated field, which is what makes the uvec4 constant's fourth
// component 1 rather than 0xFF.
EXPECT_TRUE(HasComponents(*storeMask, {0u, 5u, 6u, 7u}));
const auto readIds = CollectImageReadResultIds(widened);
ASSERT_EQ(readIds.size(), 1u);
const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front());
ASSERT_NE(loadMask, nullptr);
EXPECT_TRUE(HasComponents(*loadMask, {0u, 5u, 6u, 7u}));
}
TEST(WidenImageFormats, CoreFormatModuleIsHandedBackUntouched) {
const Vector<Uint32> spirv = CompileFragment(kCoreFormatLoadStore);
ASSERT_FALSE(spirv.empty());
// The cheap probe is what keeps every ordinary shader off the optimizer entirely.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true);
if (!widened.empty()) {
EXPECT_EQ(CollectVectorShuffles(widened).size(), CollectVectorShuffles(spirv).size())
<< "a core-format module must gain no masks";
const auto types = CollectStorageImageTypes(widened);
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba32f));
}
}
TEST(WidenImageFormats, FormatWithoutAnExactCarrierIsLeftAlone) {
const Vector<Uint32> spirv = CompileFragment(kRg16LoadStore);
ASSERT_FALSE(spirv.empty());
const auto beforeTypes = CollectStorageImageTypes(spirv);
ASSERT_EQ(beforeTypes.size(), 1u);
EXPECT_EQ(beforeTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg16));
// rg16 has no core format with 16-bit unsigned-normalized channels behind it. Anything wider
// would requantize differently from what the application asked for, so the pass declines and
// CollectImageFormatBakeInputs reports the format as unspellable instead.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*enableSpirvValidation=*/true);
if (!widened.empty()) {
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg16));
}
}
+74
View File
@@ -5212,3 +5212,77 @@ TEST_F(TextureTest, ImageFormatCompatibilityTypeAgreesAcrossEveryTexParameterGet
MG_Impl::GLImpl::DeleteTextures(1, &texture);
DrainPendingGlErrors();
}
// The image-format widening's transfer half. GL has forty image formats and GLSL ES core has
// thirteen, so an image-bindable GL_R8UI texture is stored as a GL_RGBA8UI and an image-bindable
// GL_RG32F as a GL_RGBA32F (TextureImpl::GetImageBindableStorageWidening). The driver is then told
// the transfer is four components wide, and one- or two-component client data has to be repacked to
// match - with the SAME values GL gives the channels a narrow format does not have, so that a
// later sample, imageLoad or glGetTexImage cannot tell the carrier from the real thing.
TEST_F(TextureTest, ImageWidenedUploadExpandsOneAndTwoChannelDataWithGLsMissingChannelValues) {
using MobileGL::MG_Backend::DirectGLES::TextureImpl::PrepareChannelWidenedUpload;
const IntVec3 texelSize(2, 1, 1);
// GL_RG32F -> GL_RGBA32F. Blue is 0 and alpha 1.0, which is exactly what GL answers for the
// two channels an rg32f image does not have.
{
const Float source[] = {0.25f, -0.5f, 1.5f, -2.5f};
Vector<Uint8> widened;
const auto* result = static_cast<const Float*>(
PrepareChannelWidenedUpload(2, texelSize, source, sizeof(source), GL_FLOAT, widened, false));
ASSERT_NE(result, static_cast<const void*>(source));
ASSERT_EQ(widened.size(), 8 * sizeof(Float));
const Float expected[] = {0.25f, -0.5f, 0.0f, 1.0f, 1.5f, -2.5f, 0.0f, 1.0f};
for (SizeT i = 0; i < 8; ++i) {
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
}
}
// GL_R8UI -> GL_RGBA8UI. Three added channels, and the one in alpha is the INTEGER one: an
// integer format's missing alpha reads back as 1, not as the saturated field a normalized
// format's does, and GL_UNSIGNED_BYTE serves both classes so the type alone cannot decide.
{
const Uint8 source[] = {7, 8};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
1, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, /*integerData=*/true));
ASSERT_NE(result, static_cast<const void*>(source));
const Uint8 expected[] = {7, 0, 0, 1, 8, 0, 0, 1};
ASSERT_EQ(widened.size(), sizeof(expected));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_R8 -> GL_RGBA8, the normalized twin of the case above: same transfer type, saturated one.
{
const Uint8 source[] = {7, 8};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
1, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, /*integerData=*/false));
const Uint8 expected[] = {7, 0, 0, 0xFF, 8, 0, 0, 0xFF};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_RG8_SNORM -> GL_RGBA8_SNORM keeps GL_BYTE, whose 1.0 is the positive maximum.
{
const Int8 source[] = {-1, 2, 3, -4};
Vector<Uint8> widened;
const auto* result = static_cast<const Int8*>(
PrepareChannelWidenedUpload(2, texelSize, source, sizeof(source), GL_BYTE, widened, false));
const Int8 expected[] = {-1, 2, 0, 0x7F, 3, -4, 0, 0x7F};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// A four-component source is already the carrier's shape: nothing to repack, and the caller's
// sub-rect upload fast path depends on the pointer coming back unchanged when that is so.
{
const Uint8 source[] = {1, 2, 3, 4, 5, 6, 7, 8};
Vector<Uint8> widened;
EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened,
false),
static_cast<const void*>(source));
EXPECT_TRUE(widened.empty());
}
}
@@ -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;