[Fix, Test] (MG_Util, MG_Backend/DirectGLES, MG_Backend/DirectVulkan): the two image target kinds a compute dispatch could not read - 1D-array on ES, imageBuffer on Vulkan

This commit is contained in:
2026-08-12 16:11:10 -04:00
parent 0b36621069
commit 2b46a3db96
15 changed files with 1286 additions and 9 deletions
@@ -26,6 +26,7 @@
#include "SpirvPasses/RebaseInstanceIndexPass.h"
#include "SpirvPasses/ZeroBaseVertexPass.h"
#include "SpirvPasses/NormalizeRectCoordinatesPass.h"
#include "SpirvPasses/Lower1DArrayImagesPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
@@ -824,6 +825,34 @@ namespace MobileGL {
return RunOptimizerChecked("LowerRectImages", optimizer, inputBinary, outputBinary);
}
bool ShaderCompiler::Lower1DArrayImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
// Declined rather than half-translated: after the rewrite the image is a 2D
// array, so a size query on it yields three components where the shader consumes
// two. Handing back a differently-shaped size silently is worse than leaving the
// module alone and letting the driver say what it does not like - and unlike the
// access path there is no correct answer to substitute, because the ES texture
// genuinely has a height the GL one does not.
//
// MGLOG_I, deliberately: MGLOG_E/W are compiled out at the INFO level every CI,
// retrace and release build uses, and this is exactly the diagnostic that has to
// survive to explain the shader the driver is about to reject.
if (Lower1DArrayImagesPass::BinaryQueriesA1DArrayStorageImageSize(inputBinary)) {
MGLOG_I("[spirv] Lower1DArrayImagesForEssl: the module queries the size of a 1D-array "
"storage image, which cannot be answered in the 2D-array shape ES stores it in; "
"leaving the module alone, and a strict ES driver will reject it");
outputBinary = inputBinary;
return true;
}
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(Lower1DArrayImagesPass::CreateLower1DArrayImagesPass());
return RunOptimizerChecked("Lower1DArrayImagesForEssl", optimizer, inputBinary, outputBinary);
}
bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
@@ -89,6 +89,14 @@ namespace MobileGL {
// size and rewrites the image type to 2D. See NormalizeRectCoordinatesPass for
// what it declines and why.
static bool LowerRectImages(const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary);
// GL_TEXTURE_1D_ARRAY storage images rewritten to the 2D-array shape the texture
// is actually stored in on ES, with the layer moved from the coordinate's second
// component to its third. DirectGLES transpile path only - Vulkan binds a real
// VK_IMAGE_VIEW_TYPE_1D_ARRAY and must see the module unchanged. Copies the input
// through untouched when the module declares no such image, which is every shader
// but a handful. See Lower1DArrayImagesPass for what it declines and why.
static bool Lower1DArrayImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary);
static bool RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary);
// Builds the non-indexed-draw variant of a vertex shader: every gl_BaseVertex
@@ -0,0 +1,247 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.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 "Lower1DArrayImagesPass.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_builder.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 <memory>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::InstructionBuilder;
using spvtools::opt::IRContext;
// OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS,
// 5 Sampled, 6 Format.
constexpr uint32_t kDimOperand = 1;
constexpr uint32_t kArrayedOperand = 3;
constexpr uint32_t kSampledOperand = 5;
// A 1D image that is arrayed AND is a storage image. Sampled == 2 is SPIR-V's
// "used without a sampler", i.e. exactly the image uniforms this pass exists for;
// Sampled == 1 (a sampled image) reaches SPIRV-Cross's sampler path, which
// already handles the 1D-array shape correctly and must be left to it.
bool Is1DArrayStorageImageType(const Instruction* imageType) {
return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage &&
imageType->NumInOperands() > kSampledOperand &&
static_cast<spv::Dim>(imageType->GetSingleWordInOperand(kDimOperand)) == spv::Dim::Dim1D &&
imageType->GetSingleWordInOperand(kArrayedOperand) == 1u &&
imageType->GetSingleWordInOperand(kSampledOperand) == 2u;
}
// Any Dim1D image, sampled or storage. Used only to decide whether the Image1D
// capability is still needed - deliberately wider than the rewrite's own
// predicate, so a module that also holds a non-arrayed 1D image (which this pass
// leaves to SPIRV-Cross) keeps the capability it still requires.
bool IsDim1DImageType(const Instruction* imageType) {
return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage &&
imageType->NumInOperands() > kSampledOperand &&
static_cast<spv::Dim>(imageType->GetSingleWordInOperand(kDimOperand)) == spv::Dim::Dim1D;
}
// The OpTypeImage behind whatever an image operation was handed - a bare image,
// or a pointer to one. Same unwrapping as NormalizeRectCoordinatesPass, minus the
// sampled-image case a storage image never has.
Instruction* ResolveImageType(IRContext* context, uint32_t objectId) {
auto* defUseMgr = context->get_def_use_mgr();
Instruction* object = defUseMgr->GetDef(objectId);
if (object == nullptr) return nullptr;
Instruction* type = defUseMgr->GetDef(object->type_id());
while (type != nullptr) {
switch (type->opcode()) {
case spv::Op::OpTypeImage:
return type;
case spv::Op::OpTypeSampledImage:
case spv::Op::OpTypePointer:
case spv::Op::OpTypeArray:
case spv::Op::OpTypeRuntimeArray:
// Each names its element type in its last in-operand, except arrays,
// whose element type is the FIRST. Both are reached here because an
// image uniform may be declared as an array of images.
type = defUseMgr->GetDef(type->opcode() == spv::Op::OpTypeArray ||
type->opcode() == spv::Op::OpTypeRuntimeArray
? type->GetSingleWordInOperand(0)
: type->GetSingleWordInOperand(type->NumInOperands() - 1));
continue;
default:
return nullptr;
}
}
return nullptr;
}
// The coordinate operand index for the operations that address an image's texels.
// OpImageRead and OpImageTexelPointer take (image, coordinate, ...); OpImageWrite
// takes (image, coordinate, texel).
bool TryGetCoordinateOperand(spv::Op opcode, uint32_t* coordinateOperand) {
switch (opcode) {
case spv::Op::OpImageRead:
case spv::Op::OpImageSparseRead:
case spv::Op::OpImageWrite:
case spv::Op::OpImageTexelPointer:
*coordinateOperand = 1;
return true;
default:
return false;
}
}
bool QueriesImageSize(spv::Op opcode) {
return opcode == spv::Op::OpImageQuerySize || opcode == spv::Op::OpImageQuerySizeLod ||
opcode == spv::Op::OpImageQueryLevels || opcode == spv::Op::OpImageQuerySamples;
}
} // namespace
bool Lower1DArrayImagesPass::BinaryQueriesA1DArrayStorageImageSize(const Vector<Uint32>& binary) {
if (binary.empty()) {
return false;
}
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 (auto& function : *context->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
if (!QueriesImageSize(instruction.opcode()) || instruction.NumInOperands() < 1) {
continue;
}
if (Is1DArrayStorageImageType(
ResolveImageType(context.get(), instruction.GetSingleWordInOperand(0)))) {
return true;
}
}
}
}
return false;
}
spvtools::opt::Pass::Status Lower1DArrayImagesPass::Process() {
auto* irContext = context();
auto* typeMgr = irContext->get_type_mgr();
auto* constantMgr = irContext->get_constant_mgr();
// Nothing to do unless the module actually declares one. Every other shader pays
// one walk of the type table and is handed back unchanged.
bool hasType = false;
for (const Instruction& type : irContext->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
hasType = true;
break;
}
}
if (!hasType) {
return Status::SuccessWithoutChange;
}
spvtools::opt::analysis::Integer signedInt(32, true);
spvtools::opt::analysis::Vector int3(&signedInt, 3);
const uint32_t int3TypeId = typeMgr->GetTypeInstruction(&int3);
const uint32_t intTypeId = typeMgr->GetTypeInstruction(&signedInt);
const uint32_t zeroId = constantMgr->GetSIntConstId(0);
if (int3TypeId == 0 || intTypeId == 0 || zeroId == 0) {
return Status::Failure;
}
// (u, layer) -> (u, 0, layer). The height the ES 2D array carries is 1, so Y is
// always 0 and the layer has to move from the second component to the third; a
// plain widening that appended the 0 would read layer 0 of every access instead.
for (auto& function : *irContext->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
uint32_t coordinateOperand = 0;
if (!TryGetCoordinateOperand(instruction.opcode(), &coordinateOperand) ||
instruction.NumInOperands() <= coordinateOperand) {
continue;
}
if (!Is1DArrayStorageImageType(
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0)))) {
continue;
}
const uint32_t coordinateId = instruction.GetSingleWordInOperand(coordinateOperand);
InstructionBuilder builder(
irContext, &instruction,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
Instruction* u =
builder.AddCompositeExtract(intTypeId, coordinateId, {0});
Instruction* layer =
builder.AddCompositeExtract(intTypeId, coordinateId, {1});
if (u == nullptr || layer == nullptr) {
return Status::Failure;
}
Instruction* widened = builder.AddCompositeConstruct(
int3TypeId, {u->result_id(), zeroId, layer->result_id()});
if (widened == nullptr) {
return Status::Failure;
}
instruction.SetInOperand(coordinateOperand, {widened->result_id()});
irContext->UpdateDefUse(&instruction);
}
}
}
// Only now, with no access still spelling the 1D-array coordinate, does the type
// become the 2D array one. Arrayed stays 1: this is a 2D ARRAY image, which is
// what the texture was stored as.
for (Instruction& type : irContext->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
type.SetInOperand(kDimOperand, {static_cast<uint32_t>(spv::Dim::Dim2D)});
}
}
// Image1D describes the types just rewritten - but only drop it if no 1D image
// type is left at all. A module may hold a non-arrayed 1D storage image, which
// this pass deliberately leaves to SPIRV-Cross, and that one still needs the
// capability. Shader is always declared by any module reaching here, so restating
// it keeps the instruction valid without leaving a capability a consumer could
// key off.
bool anyDim1DLeft = false;
for (const Instruction& type : irContext->types_values()) {
if (IsDim1DImageType(&type)) {
anyDim1DLeft = true;
break;
}
}
if (!anyDim1DLeft) {
for (Instruction& capability : irContext->capabilities()) {
const auto value = static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (value == spv::Capability::Image1D) {
capability.SetInOperand(0, {static_cast<uint32_t>(spv::Capability::Shader)});
}
}
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken Lower1DArrayImagesPass::CreateLower1DArrayImagesPass() {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<Lower1DArrayImagesPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,78 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.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 {
// ES has no 1D texture of any kind, so a GL_TEXTURE_1D_ARRAY is stored as an ES 2D
// array with height 1 and the layers in depth (TextureImpl::MapToBackendTextureTarget
// and GetBackendUploadSize, MG_Backend/DirectGLES/Managers.h). The shader side has to
// agree, and for SAMPLERS it does: SPIRV-Cross rewrites a 1D-array lookup into a
// 2D-array one and moves the layer into the third component itself
// (spirv_glsl.cpp, `if (imgtype.image.arrayed) ... ".x, 0.0, " ... ".y"`).
//
// For IMAGES it does not. The image path applies the same 1D emulation without ever
// asking whether the type is arrayed:
//
// if (type.image.dim == Dim1D && options.es)
// coord_expr = join("ivec2(", coord_expr, ", 0)");
//
// For a non-arrayed 1D image that is right - a scalar coordinate becomes (u, 0). For
// a 1D ARRAY image the coordinate is already the two-component (u, layer), so the
// result is `ivec2(ivec2(u, layer), 0)`: three components crammed into a two-component
// constructor. Every ES driver rejects it outright, and the whole program is lost -
// which is how one uimage1DArray uniform took the entire eleven-image compute shader
// of KHR-GL44.multi_bind.dispatch_bind_image_textures down with it, with the driver
// saying only "'constructor' : too many arguments".
//
// Widening the constructor would not be enough either. `ivec3(u, layer, 0)` puts the
// layer in the 2D array's Y and reads layer 0, whereas the storage this has to match
// puts height at 1 and the layers in Z, so the correct coordinate is (u, 0, layer).
//
// So this pass does the whole conversion in the module, before SPIRV-Cross sees it:
// every 1D-array STORAGE image type becomes a 2D-array one, and every read and write
// through it has its coordinate widened from (u, layer) to (u, 0, layer). SPIRV-Cross
// is then looking at an ordinary 2D array image and its 1D path never fires.
//
// Deliberately narrow, on three axes:
//
// * STORAGE images only (Sampled == 2). Sampled images reach SPIRV-Cross's sampler
// path, which is correct today; rewriting them would replace working emission
// with our own for no reason.
// * ARRAYED only. A non-arrayed 1D storage image is emitted correctly by the same
// SPIRV-Cross code, and is left to it.
// * ESSL only. Vulkan has VK_IMAGE_VIEW_TYPE_1D_ARRAY natively and Magma binds it
// directly, so the module must reach that backend unchanged.
//
// A size query on one of these images is DECLINED rather than half-translated: after
// the rewrite OpImageQuerySize yields three components where the shader consumes two,
// and silently handing back a differently-shaped size is worse than refusing. The
// caller logs it and leaves the module alone.
class Lower1DArrayImagesPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-lower-1d-array-images"; }
Status Process() override;
// True when the module declares a 1D-array storage image whose size is queried,
// which is the shape this pass refuses to translate. Checked by the caller before
// running, so a declined module is handed on untouched rather than partly
// rewritten.
static bool BinaryQueriesA1DArrayStorageImageSize(const Vector<Uint32>& binary);
static spvtools::Optimizer::PassToken CreateLower1DArrayImagesPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL