[Fix, Test] (ShaderTranspiler, DirectGLES): widen a non-arrayed 1D storage image's atomic coordinate

This commit is contained in:
2026-08-20 22:46:32 -04:00
parent cd07d42a47
commit 4154f2e941
6 changed files with 296 additions and 81 deletions
@@ -948,26 +948,27 @@ namespace MobileGL {
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
// (array) one, so a size query on it yields a component more than the shader
// consumes. 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_W, latched: per shader compile, and shader packs compile lazily
// mid-session. (Parked at MGLOG_I until the Log.h ordering fix made W live.)
const auto traits = Lower1DArrayImagesPass::InspectBinary(inputBinary);
// The overwhelmingly common answer, and the reason the inspection exists: no
// 1D-array storage image, so the module is handed back byte for byte without an
// Optimizer ever being built. Every ESSL shader in the process passes through
// here, so the cost of the case with nothing to do is the cost of this pass.
// 1D storage image this pass owns, so the module is handed back byte for byte
// without an Optimizer ever being built. Every ESSL shader in the process passes
// through here, so the cost of the case with nothing to do is the cost of this
// pass.
if (!traits.declaresImage) {
outputBinary = inputBinary;
return true;
}
if (traits.queriesImageSize) {
MGLOG_W_ONCE("[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; "
MGLOG_W_ONCE("[spirv] Lower1DArrayImagesForEssl: the module queries the size of a 1D "
"storage image, which cannot be answered in the 2D shape ES stores it in; "
"leaving the module alone, and a strict ES driver will reject it");
outputBinary = inputBinary;
return true;
@@ -975,8 +976,8 @@ namespace MobileGL {
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(Lower1DArrayImagesPass::CreateLower1DArrayImagesPass());
// Mandatory, not tidying. Rewriting a 1D-array image type to the 2D-array one
// makes it structurally IDENTICAL to any real 2D-array image of the same sampled
// Mandatory, not tidying. Rewriting a 1D(-array) image type to the 2D(-array) one
// makes it structurally IDENTICAL to any real 2D(-array) image of the same sampled
// type and format that the module already declared - and SPIR-V forbids duplicate
// non-aggregate type declarations, so the result fails validation. That collision
// is not exotic: it is the shape of this whole change's headline case, where one
@@ -168,10 +168,13 @@ namespace MobileGL {
bool enableSpirvValidation = false);
// 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.
// component to its third - and, when the module performs an image ATOMIC on one,
// the non-arrayed GL_TEXTURE_1D storage image to the 2D shape with its coordinate
// widened to (u, 0), which is the one 1D shape SPIRV-Cross does not widen itself.
// 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,
bool enableSpirvValidation = false);
@@ -49,10 +49,22 @@ namespace MobileGL {
imageType->GetSingleWordInOperand(kSampledOperand) == 2u;
}
// The other half of the 1D storage-image family: not arrayed. SPIRV-Cross emits
// read and write through one of these correctly, and an ATOMIC through one
// incorrectly (see the header), so this predicate only ever decides anything
// together with the atomic probe below.
bool Is1DNonArrayedStorageImageType(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) == 0u &&
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.
// predicate, so a module that also holds a 1D image this pass left alone keeps the
// capability it still requires.
bool IsDim1DImageType(const Instruction* imageType) {
return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage &&
imageType->NumInOperands() > kSampledOperand &&
@@ -110,6 +122,60 @@ namespace MobileGL {
return opcode == spv::Op::OpImageQuerySize || opcode == spv::Op::OpImageQuerySizeLod ||
opcode == spv::Op::OpImageQueryLevels || opcode == spv::Op::OpImageQuerySamples;
}
// Which 1D storage images this module is to be rewritten for. Arrayed ones always;
// non-arrayed ones only when an atomic reaches one, because that is the only shape
// SPIRV-Cross gets wrong for them and taking over a path it gets right would be a
// regression looking for somewhere to happen.
struct LoweringScope {
bool arrayed = false;
bool nonArrayed = false;
bool Any() const { return arrayed || nonArrayed; }
bool Covers(const Instruction* imageType) const {
return (arrayed && Is1DArrayStorageImageType(imageType)) ||
(nonArrayed && Is1DNonArrayedStorageImageType(imageType));
}
};
// OpImageTexelPointer is the operand path of every imageAtomic*; nothing else in a
// GLSL-derived module produces one.
bool PerformsAtomicOnNonArrayed1DImage(IRContext* context) {
for (auto& function : *context->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
if (instruction.opcode() != spv::Op::OpImageTexelPointer ||
instruction.NumInOperands() < 1) {
continue;
}
if (Is1DNonArrayedStorageImageType(
ResolveImageType(context, instruction.GetSingleWordInOperand(0)))) {
return true;
}
}
}
}
return false;
}
// One walk of the type table, then - and only when the module declares a
// non-arrayed 1D storage image at all - one walk of the code. Every other shader
// pays the type walk and nothing else.
LoweringScope ResolveLoweringScope(IRContext* context) {
LoweringScope scope;
bool hasNonArrayed = false;
for (const Instruction& type : context->module()->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
scope.arrayed = true;
} else if (Is1DNonArrayedStorageImageType(&type)) {
hasNonArrayed = true;
}
}
if (hasNonArrayed) {
scope.nonArrayed = PerformsAtomicOnNonArrayed1DImage(context);
}
return scope;
}
} // namespace
Lower1DArrayImagesPass::ModuleTraits Lower1DArrayImagesPass::InspectBinary(const Vector<Uint32>& binary) {
@@ -126,15 +192,11 @@ namespace MobileGL {
// The type table settles it for the cheap half, and it is the half almost every
// shader takes: no such type declared, nothing to inspect further.
for (const Instruction& type : context->module()->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
traits.declaresImage = true;
break;
}
}
if (!traits.declaresImage) {
const LoweringScope scope = ResolveLoweringScope(context.get());
if (!scope.Any()) {
return traits;
}
traits.declaresImage = true;
for (auto& function : *context->module()) {
for (auto& block : function) {
@@ -142,7 +204,7 @@ namespace MobileGL {
if (!QueriesImageSize(instruction.opcode()) || instruction.NumInOperands() < 1) {
continue;
}
if (Is1DArrayStorageImageType(
if (scope.Covers(
ResolveImageType(context.get(), instruction.GetSingleWordInOperand(0)))) {
traits.queriesImageSize = true;
return traits;
@@ -160,27 +222,21 @@ namespace MobileGL {
// 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) {
const LoweringScope scope = ResolveLoweringScope(irContext);
if (!scope.Any()) {
return Status::SuccessWithoutChange;
}
// The same refusal the caller makes, restated here so the pass is safe wherever
// it is registered. Rewriting the type while leaving an OpImageQuerySize on it
// produces a query whose result type has one component too few - an invalid
// module - and there is no correct two-component size to substitute, because the
// ES texture genuinely has a height the GL one does not.
// module - and there is no correct narrower size to substitute, because the ES
// texture genuinely has a height the GL one does not.
for (auto& function : *irContext->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
if (QueriesImageSize(instruction.opcode()) && instruction.NumInOperands() >= 1 &&
Is1DArrayStorageImageType(
scope.Covers(
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0)))) {
return Status::SuccessWithoutChange;
}
@@ -188,9 +244,12 @@ namespace MobileGL {
}
}
// (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.
// Arrayed: (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. Non-arrayed: u -> (u, 0), which is exactly what SPIRV-Cross itself
// writes for the operations it does widen - reproduced here so read, write and
// atomic all come out of one place.
for (auto& function : *irContext->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
@@ -199,38 +258,44 @@ namespace MobileGL {
instruction.NumInOperands() <= coordinateOperand) {
continue;
}
if (!Is1DArrayStorageImageType(
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0)))) {
const Instruction* imageType =
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0));
if (!scope.Covers(imageType)) {
continue;
}
const bool arrayed = Is1DArrayStorageImageType(imageType);
const uint32_t coordinateId = instruction.GetSingleWordInOperand(coordinateOperand);
// Built from the COORDINATE's own component type rather than a
// hardcoded signed int. GLSL only ever spells these ivec2, but SPIR-V
// permits an unsigned coordinate, and extracting a uint component
// into an int result is an invalid module rather than a wrong answer -
// the kind of defect that reaches a driver as "compiles here, not
// there".
// hardcoded signed int. GLSL only ever spells these int/ivec2, but
// SPIR-V permits an unsigned coordinate, and extracting a uint
// component into an int result is an invalid module rather than a
// wrong answer - the kind of defect that reaches a driver as "compiles
// here, not there".
Instruction* coordinateDef = irContext->get_def_use_mgr()->GetDef(coordinateId);
if (coordinateDef == nullptr) return Status::Failure;
const auto* coordinateType = typeMgr->GetType(coordinateDef->type_id());
const auto* coordinateVector = coordinateType != nullptr ? coordinateType->AsVector()
: nullptr;
if (coordinateVector == nullptr || coordinateVector->element_count() != 2) {
if (coordinateType == nullptr) return Status::Failure;
// Arrayed coordinates are the two-component (u, layer); non-arrayed
// ones are the bare scalar u. Anything else is a shape this pass does
// not translate, and declining leaves the module byte for byte.
const auto* coordinateVector = arrayed ? coordinateType->AsVector() : nullptr;
if (arrayed && (coordinateVector == nullptr || coordinateVector->element_count() != 2)) {
return Status::Failure;
}
const auto* component = coordinateVector->element_type();
const auto* component =
arrayed ? coordinateVector->element_type() : coordinateType;
const auto* componentInteger = component != nullptr ? component->AsInteger() : nullptr;
if (componentInteger == nullptr) return Status::Failure;
spvtools::opt::analysis::Vector widenedVector(component, 3);
const uint32_t int3TypeId = typeMgr->GetTypeInstruction(&widenedVector);
spvtools::opt::analysis::Vector widenedVector(component, arrayed ? 3 : 2);
const uint32_t widenedTypeId = typeMgr->GetTypeInstruction(&widenedVector);
const uint32_t intTypeId = typeMgr->GetTypeInstruction(component);
const uint32_t zeroId = componentInteger->IsSigned()
? constantMgr->GetSIntConstId(0)
: constantMgr->GetUIntConstId(0);
if (int3TypeId == 0 || intTypeId == 0 || zeroId == 0) {
if (widenedTypeId == 0 || intTypeId == 0 || zeroId == 0) {
return Status::Failure;
}
@@ -238,15 +303,20 @@ namespace MobileGL {
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 = nullptr;
if (arrayed) {
Instruction* u =
builder.AddCompositeExtract(intTypeId, coordinateId, {0});
Instruction* layer =
builder.AddCompositeExtract(intTypeId, coordinateId, {1});
if (u == nullptr || layer == nullptr) {
return Status::Failure;
}
widened = builder.AddCompositeConstruct(
widenedTypeId, {u->result_id(), zeroId, layer->result_id()});
} else {
widened = builder.AddCompositeConstruct(widenedTypeId, {coordinateId, zeroId});
}
Instruction* widened = builder.AddCompositeConstruct(
int3TypeId, {u->result_id(), zeroId, layer->result_id()});
if (widened == nullptr) {
return Status::Failure;
}
@@ -256,21 +326,22 @@ namespace MobileGL {
}
}
// 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.
// Only now, with no access still spelling a 1D coordinate, does the type become
// the 2D one. Arrayed is left exactly as it was - a 1D array becomes a 2D ARRAY
// image, which is what the texture was stored as, and a non-arrayed 1D becomes the
// plain 2D image MobileGL stores a GL_TEXTURE_1D in (height 1).
for (Instruction& type : irContext->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
if (scope.Covers(&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.
// type is left at all. A module may hold a 1D image this pass left alone (a
// SAMPLED one always, and a non-arrayed storage one whenever no atomic reaches
// it), 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)) {
@@ -46,13 +46,30 @@ namespace MobileGL {
// 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.
//
// The NON-arrayed 1D storage image is handled too, but only in one shape. SPIRV-Cross
// applies the widening above in OpImageRead (spirv_glsl.cpp) and in OpImageWrite - and
// NOT in OpImageTexelPointer, which is the operand path every imageAtomic* goes
// through. So `imageAtomicAdd(g_image_1d, coord.x, 2)` comes out with a SCALAR
// coordinate against a variable it declared `iimage2D`, and the ES compiler answers
// "'imageAtomicAdd' : no matching overloaded function found" - losing the whole stage
// and with it every other image in it, which is how
// KHR-GL4x.shader_image_load_store.basic-allTargets-atomic lost a seven-image fragment
// shader over one of them.
//
// That case is lowered here for the same reason as the arrayed one: the type becomes
// Dim2D and every coordinate is widened from u to (u, 0) in the module, so SPIRV-Cross
// has no 1D image left to emulate and read, write and atomic are all spelled by one
// piece of code. It is gated on the module ACTUALLY performing an image atomic on such
// an image, so a shader that only loads and stores through a 1D image keeps taking
// SPIRV-Cross's own (correct) emission byte for byte and this pass cannot regress it.
//
// 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.
// * ARRAYED always; NON-arrayed only when the module holds an OpImageTexelPointer
// into one, i.e. only when SPIRV-Cross's own emission is already broken for 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.
//
@@ -81,13 +98,15 @@ namespace MobileGL {
// cost one module parse and no optimizer run at all - not one parse to ask about
// size queries and a second inside an Optimizer that then early-outs.
struct ModuleTraits {
// The module declares a 1D-array storage image, i.e. there is anything to do.
// The module declares an image this pass would rewrite - a 1D-array storage
// image, or a non-arrayed 1D storage image the module performs an atomic on -
// i.e. there is anything to do.
bool declaresImage = false;
// ...and queries its size, which is the shape this pass refuses to translate:
// afterwards the image is a 2D array, so the query yields three components
// where the shader consumes two, and there is no correct two-component answer
// to substitute. The caller leaves such a module alone rather than half
// rewriting it.
// afterwards the image is a 2D (array) one, so the query yields a component
// more than the shader consumes, and there is no correct narrower answer to
// substitute. The caller leaves such a module alone rather than half rewriting
// it.
bool queriesImageSize = false;
};
static ModuleTraits InspectBinary(const Vector<Uint32>& binary);