// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.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 "Lower1DSampledImagesPass.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 #include namespace MobileGL { namespace MG_Util { namespace ShaderTranspiler { namespace { using spvtools::opt::Instruction; using spvtools::opt::InstructionBuilder; using spvtools::opt::IRContext; 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 kDimOperand = 1; constexpr uint32_t kArrayedOperand = 3; constexpr uint32_t kSampledOperand = 5; // Sampled == 1 is SPIR-V's "used WITH a sampler", i.e. exactly the sampler // uniforms this pass exists for. Sampled == 2 is the storage image // Lower1DArrayImagesPass owns, and Sampled == 0 ("either") is a shape glslang // never emits from GLSL - left out so an unexpected module is declined rather // than rewritten on a guess. bool Is1DSampledImageType(const Instruction* imageType) { return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage && imageType->NumInOperands() > kSampledOperand && static_cast(imageType->GetSingleWordInOperand(kDimOperand)) == spv::Dim::Dim1D && imageType->GetSingleWordInOperand(kSampledOperand) == 1u; } bool Is1DSampledImageTypeOfArrayedness(const Instruction* imageType, bool arrayed) { return Is1DSampledImageType(imageType) && (imageType->GetSingleWordInOperand(kArrayedOperand) == 1u) == arrayed; } // Any Dim1D image still declared with Sampled == 1. Used only to decide whether // the Sampled1D capability is still needed after the rewrite. bool AnyDim1DSampledTypeLeft(IRContext* context) { for (const Instruction& type : context->module()->types_values()) { if (Is1DSampledImageType(&type)) return true; } return false; } // The OpTypeImage behind whatever an image operation was handed - a bare image, a // sampled image, or a pointer/array of either. Same unwrapping as // Lower1DArrayImagesPass, which needs the identical walk. 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 a // sampler uniform may be declared as an array of samplers. 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; } // How this pass classifies an opcode that can touch one of these images. enum class OpKind { // Not an image operation at all: it may CARRY the image or sampled-image // value (OpLoad, OpSampledImage, OpCopyObject, ...) but it names no // coordinate, so the rewrite does not reach it. NotImageOp, // Addresses texels: has a coordinate at in-operand 1 and, from // `imageOperandsIndex`, an optional image-operands mask. Texel, // Reads a property whose result does not depend on Dim. Safe to leave. DimIndependentQuery, // Recognised, and refused: rewriting the type would change the shape of what // the shader consumes, or the operation is one this pass has no translation // for. Decline, }; struct OpClassification { OpKind kind = OpKind::NotImageOp; uint32_t coordinateOperand = 1; // In-operand index of the ImageOperands mask, when the opcode has one. The // mask itself is OPTIONAL for the implicit-Lod, fetch and gather forms, so // this is an index to test against NumInOperands(), not a promise. uint32_t imageOperandsIndex = 0; }; OpClassification ClassifyOpcode(spv::Op opcode) { switch (opcode) { // (image, coordinate, [operands]) - the mask, when present, is in-operand 2. case spv::Op::OpImageSampleImplicitLod: case spv::Op::OpImageSampleExplicitLod: case spv::Op::OpImageSampleProjImplicitLod: case spv::Op::OpImageSampleProjExplicitLod: case spv::Op::OpImageFetch: case spv::Op::OpImageSparseSampleImplicitLod: case spv::Op::OpImageSparseSampleExplicitLod: case spv::Op::OpImageSparseSampleProjImplicitLod: case spv::Op::OpImageSparseSampleProjExplicitLod: case spv::Op::OpImageSparseFetch: return {OpKind::Texel, 1u, 2u}; // (image, coordinate, D_ref, [operands]) - one operand more before the mask. case spv::Op::OpImageSampleDrefImplicitLod: case spv::Op::OpImageSampleDrefExplicitLod: case spv::Op::OpImageSampleProjDrefImplicitLod: case spv::Op::OpImageSampleProjDrefExplicitLod: case spv::Op::OpImageSparseSampleDrefImplicitLod: case spv::Op::OpImageSparseSampleDrefExplicitLod: case spv::Op::OpImageSparseSampleProjDrefImplicitLod: case spv::Op::OpImageSparseSampleProjDrefExplicitLod: return {OpKind::Texel, 1u, 3u}; // OpImageQueryLod names a coordinate and no mask. Its coordinate is the PLANE // components only (no array layer), which the insert-at-1 rule widens just as // correctly as a sampling coordinate. case spv::Op::OpImageQueryLod: return {OpKind::Texel, 1u, /*no mask*/ 0xFFFFFFFFu}; // Scalar result, identical for Dim1D and Dim2D. case spv::Op::OpImageQueryLevels: return {OpKind::DimIndependentQuery, 0u, 0u}; // textureSize: int for a sampler1D, ivec2 for the sampler2D it would become. // There is no correct narrower answer to substitute, so the module is left // alone - the sibling pass refuses the same shape for the same reason. case spv::Op::OpImageQuerySize: case spv::Op::OpImageQuerySizeLod: // Gather is not available for 1D samplers in GLSL, so reaching one here means // an input this pass did not anticipate; and its ConstOffsets operand is an // ARRAY of offsets whose widening this pass does not implement. case spv::Op::OpImageGather: case spv::Op::OpImageDrefGather: case spv::Op::OpImageSparseGather: case spv::Op::OpImageSparseDrefGather: // Storage-image traffic has no business reaching a Sampled == 1 image; if it // does, the module is not the shape this pass reasoned about. case spv::Op::OpImageRead: case spv::Op::OpImageWrite: case spv::Op::OpImageSparseRead: case spv::Op::OpImageTexelPointer: case spv::Op::OpImageQuerySamples: return {OpKind::Decline, 0u, 0u}; default: return {OpKind::NotImageOp, 0u, 0u}; } } // How many ids each ImageOperands bit contributes, in the bit order SPIR-V lays // them out in. Only the bits that carry ids need an entry; the rest contribute // nothing and are skipped by having a count of zero. struct ImageOperandBit { spv::ImageOperandsMask bit; uint32_t idCount; }; constexpr ImageOperandBit kImageOperandBits[] = { {spv::ImageOperandsMask::Bias, 1u}, {spv::ImageOperandsMask::Lod, 1u}, {spv::ImageOperandsMask::Grad, 2u}, {spv::ImageOperandsMask::ConstOffset, 1u}, {spv::ImageOperandsMask::Offset, 1u}, {spv::ImageOperandsMask::ConstOffsets, 1u}, {spv::ImageOperandsMask::Sample, 1u}, {spv::ImageOperandsMask::MinLod, 1u}, {spv::ImageOperandsMask::MakeTexelAvailable, 1u}, {spv::ImageOperandsMask::MakeTexelVisible, 1u}, {spv::ImageOperandsMask::NonPrivateTexel, 0u}, {spv::ImageOperandsMask::VolatileTexel, 0u}, {spv::ImageOperandsMask::SignExtend, 0u}, {spv::ImageOperandsMask::ZeroExtend, 0u}, {spv::ImageOperandsMask::Nontemporal, 0u}, {spv::ImageOperandsMask::Offsets, 1u}, }; // Where each of the operands this pass rewrites sits, for one instruction. An // index of 0 means "not present" - in-operand 0 is always the image, so it can // never be a real position for one of these. struct OperandPositions { uint32_t gradX = 0; uint32_t gradY = 0; uint32_t constOffset = 0; uint32_t offset = 0; // A bit this pass does not know how to widen appeared on a covered image. bool unsupported = false; bool Any() const { return gradX != 0 || constOffset != 0 || offset != 0; } }; OperandPositions LocateOperands(const Instruction& instruction, uint32_t imageOperandsIndex) { OperandPositions positions; if (imageOperandsIndex == 0xFFFFFFFFu || instruction.NumInOperands() <= imageOperandsIndex) { return positions; } const uint32_t mask = instruction.GetSingleWordInOperand(imageOperandsIndex); uint32_t next = imageOperandsIndex + 1u; for (const ImageOperandBit& entry : kImageOperandBits) { if ((mask & static_cast(entry.bit)) == 0u) continue; switch (entry.bit) { case spv::ImageOperandsMask::Grad: positions.gradX = next; positions.gradY = next + 1u; break; case spv::ImageOperandsMask::ConstOffset: positions.constOffset = next; break; case spv::ImageOperandsMask::Offset: positions.offset = next; break; case spv::ImageOperandsMask::ConstOffsets: case spv::ImageOperandsMask::Offsets: // An array of offsets, only meaningful for gather - which is declined // above. Refuse rather than translate half of it. positions.unsupported = true; break; default: break; } next += entry.idCount; } // Every id the mask claimed has to actually be there; a truncated operand // list means the instruction is not the shape this walk assumed. if (next > instruction.NumInOperands()) { positions.unsupported = true; } return positions; } // Whether this instruction so much as mentions a value whose type resolves to a // covered image. Used to make sure nothing reaches these images through an opcode // this pass never considered: the answer decides between rewriting and declining, // never between two different rewrites. template bool MentionsCoveredImage(IRContext* context, const Instruction& instruction, const CoveredFn& covered) { bool mentions = false; instruction.ForEachInId([&](const uint32_t* id) { if (mentions || id == nullptr) return; if (covered(ResolveImageType(context, *id))) mentions = true; }); return mentions; } // The component type of a value, and how many of them it has. A scalar reports a // count of 1; anything that is neither an int/float scalar nor a vector of one // reports 0, which every caller treats as "not a shape this pass translates". struct ValueShape { const analysis::Type* componentType = nullptr; uint32_t componentCount = 0; bool IsScalar() const { return componentCount == 1u; } }; ValueShape DescribeValue(IRContext* context, uint32_t valueId) { ValueShape shape; Instruction* def = context->get_def_use_mgr()->GetDef(valueId); if (def == nullptr) return shape; const analysis::Type* type = context->get_type_mgr()->GetType(def->type_id()); if (type == nullptr) return shape; const analysis::Vector* asVector = type->AsVector(); const analysis::Type* component = asVector != nullptr ? asVector->element_type() : type; if (component == nullptr) return shape; if (component->AsInteger() == nullptr && component->AsFloat() == nullptr) { return shape; } shape.componentType = component; shape.componentCount = asVector != nullptr ? asVector->element_count() : 1u; return shape; } // Which 1D sampled images this module is to be rewritten for, decided per // arrayed-ness because that is the granularity of the OpTypeImage declarations // glslang emits. A category is in scope only when the module actually performs a // lookup on it carrying an Offset, ConstOffset or Grad - the operands SPIRV-Cross // prints with the wrong arity - so a shader that only samples and fetches keeps // SPIRV-Cross's own correct emission untouched. struct LoweringScope { bool arrayed = false; bool nonArrayed = false; bool Any() const { return arrayed || nonArrayed; } bool Covers(const Instruction* imageType) const { return (arrayed && Is1DSampledImageTypeOfArrayedness(imageType, true)) || (nonArrayed && Is1DSampledImageTypeOfArrayedness(imageType, false)); } }; LoweringScope ResolveLoweringScope(IRContext* context) { LoweringScope scope; // The type table settles the common case, and it is nearly every shader: no // 1D sampled image declared at all, so the code is never walked. bool declared = false; for (const Instruction& type : context->module()->types_values()) { if (Is1DSampledImageType(&type)) { declared = true; break; } } if (!declared) return scope; for (auto& function : *context->module()) { for (auto& block : function) { for (auto& instruction : block) { const OpClassification classification = ClassifyOpcode(instruction.opcode()); if (classification.kind != OpKind::Texel || instruction.NumInOperands() <= classification.coordinateOperand) { continue; } const Instruction* imageType = ResolveImageType(context, instruction.GetSingleWordInOperand(0)); if (!Is1DSampledImageType(imageType)) continue; const OperandPositions positions = LocateOperands(instruction, classification.imageOperandsIndex); if (!positions.Any()) continue; if (imageType->GetSingleWordInOperand(kArrayedOperand) == 1u) { scope.arrayed = true; } else { scope.nonArrayed = true; } } } } return scope; } // Everything this pass will touch, collected before a single word is changed. // Planning first is what lets every refusal be a clean "leave the module alone": // there is no point at which the module is half converted and the pass then // discovers it cannot finish. struct RewritePlan { struct Site { Instruction* instruction = nullptr; uint32_t coordinateOperand = 0; OperandPositions operands; }; std::vector sites; bool declined = false; }; RewritePlan PlanRewrite(IRContext* context, const LoweringScope& scope) { RewritePlan plan; const auto covered = [&scope](const Instruction* type) { return scope.Covers(type); }; for (auto& function : *context->module()) { for (auto& block : function) { for (auto& instruction : block) { const OpClassification classification = ClassifyOpcode(instruction.opcode()); if (classification.kind == OpKind::NotImageOp || classification.kind == OpKind::DimIndependentQuery) { // These name no coordinate, so they need no rewrite - but an // opcode this pass has never classified must not reach one of // these images unnoticed. NotImageOp is the catch-all, so the // check is on it. if (classification.kind == OpKind::NotImageOp && instruction.opcode() != spv::Op::OpLoad && instruction.opcode() != spv::Op::OpStore && instruction.opcode() != spv::Op::OpCopyObject && instruction.opcode() != spv::Op::OpSampledImage && instruction.opcode() != spv::Op::OpImage && instruction.opcode() != spv::Op::OpAccessChain && instruction.opcode() != spv::Op::OpInBoundsAccessChain && instruction.opcode() != spv::Op::OpPhi && instruction.opcode() != spv::Op::OpSelect && instruction.opcode() != spv::Op::OpFunctionCall && MentionsCoveredImage(context, instruction, covered)) { plan.declined = true; return plan; } continue; } if (instruction.NumInOperands() < 1) continue; const Instruction* imageType = ResolveImageType(context, instruction.GetSingleWordInOperand(0)); if (!scope.Covers(imageType)) continue; if (classification.kind == OpKind::Decline) { plan.declined = true; return plan; } if (instruction.NumInOperands() <= classification.coordinateOperand) { plan.declined = true; return plan; } const OperandPositions positions = LocateOperands(instruction, classification.imageOperandsIndex); if (positions.unsupported) { plan.declined = true; return plan; } // Confirm here, before anything is written, that every operand // about to be widened has the shape the widening assumes. The // coordinate may be a scalar or a short vector; the offset and // the two gradients must be SCALARS, which for a Dim1D image is // not an assumption but the validator's own rule // (GetPlaneCoordSize(1D) == 1). Checking it up front is what // keeps the apply phase total. const ValueShape coordinate = DescribeValue( context, instruction.GetSingleWordInOperand( classification.coordinateOperand)); if (coordinate.componentCount == 0u || coordinate.componentCount > 3u) { plan.declined = true; return plan; } const uint32_t scalarOperands[] = {positions.gradX, positions.gradY, positions.offset, positions.constOffset}; for (const uint32_t position : scalarOperands) { if (position == 0u) continue; if (!DescribeValue(context, instruction.GetSingleWordInOperand(position)) .IsScalar()) { plan.declined = true; return plan; } } // ConstOffset has to stay a constant expression, so its widened // form is built as a module-scope constant - which is only // possible if the operand really is one. if (positions.constOffset != 0u && context->get_constant_mgr()->FindDeclaredConstant( instruction.GetSingleWordInOperand(positions.constOffset)) == nullptr) { plan.declined = true; return plan; } plan.sites.push_back( {&instruction, classification.coordinateOperand, positions}); } } } return plan; } } // namespace bool Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage( const Vector& binary) { if (binary.empty()) { return false; } std::unique_ptr 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; } return ResolveLoweringScope(context.get()).Any(); } spvtools::opt::Pass::Status Lower1DSampledImagesPass::Process() { auto* irContext = context(); auto* typeMgr = irContext->get_type_mgr(); auto* constantMgr = irContext->get_constant_mgr(); const LoweringScope scope = ResolveLoweringScope(irContext); if (!scope.Any()) { return Status::SuccessWithoutChange; } RewritePlan plan = PlanRewrite(irContext, scope); if (plan.declined) { return Status::SuccessWithoutChange; } // A zero of a given 32-bit scalar type. The literal word is the VALUE's bit // pattern, which for a float zero is 0 as well - so one helper serves the integer // coordinate of a fetch, the float coordinate of a sample and the float gradients // alike, without a second spelling to keep in step. const auto zeroOf = [&](const analysis::Type* componentType, uint32_t componentTypeId) -> uint32_t { const analysis::Constant* constant = constantMgr->GetConstant(componentType, {0u}); if (constant == nullptr) return 0u; const Instruction* defining = constantMgr->GetDefiningInstruction(constant, componentTypeId); return defining != nullptr ? defining->result_id() : 0u; }; // The whole of the arity repair, in one place: insert a zero at component 1. // Scalar u becomes (u, 0); (u, layer) becomes (u, 0, layer); (u, q) becomes // (u, 0, q). See the header for why one rule covers every shape. const auto widen = [&](uint32_t valueId, Instruction* before, bool mustBeConstant) -> uint32_t { const ValueShape shape = DescribeValue(irContext, valueId); if (shape.componentCount == 0u) return 0u; const uint32_t componentTypeId = typeMgr->GetTypeInstruction(shape.componentType); if (componentTypeId == 0u) return 0u; analysis::Vector widenedCandidate(shape.componentType, shape.componentCount + 1u); const uint32_t widenedTypeId = typeMgr->GetTypeInstruction(&widenedCandidate); const uint32_t zeroId = zeroOf(shape.componentType, componentTypeId); if (widenedTypeId == 0u || zeroId == 0u) return 0u; // ConstOffset must remain a constant expression - the validator says so // outright ("Expected Image Operand ConstOffset to be a const object") - so // for it the widened value is built as a module-scope OpConstantComposite // rather than as an instruction in the block. Only the scalar shape is // reachable: the plan phase refuses anything else, because a Dim1D image's // offset has exactly one component by the validator's own arity rule. if (mustBeConstant) { if (!shape.IsScalar()) return 0u; const analysis::Type* widenedType = typeMgr->GetType(widenedTypeId); const analysis::Constant* widenedConstant = widenedType != nullptr ? constantMgr->GetConstant(widenedType, {valueId, zeroId}) : nullptr; if (widenedConstant == nullptr) return 0u; const Instruction* defining = constantMgr->GetDefiningInstruction(widenedConstant, widenedTypeId); return defining != nullptr ? defining->result_id() : 0u; } InstructionBuilder builder( irContext, before, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping); std::vector componentIds; componentIds.reserve(shape.componentCount + 1u); if (shape.IsScalar()) { componentIds.push_back(valueId); componentIds.push_back(zeroId); } else { for (uint32_t i = 0; i < shape.componentCount; ++i) { Instruction* extracted = builder.AddCompositeExtract(componentTypeId, valueId, {i}); if (extracted == nullptr) return 0u; componentIds.push_back(extracted->result_id()); if (i == 0u) componentIds.push_back(zeroId); } } Instruction* widened = builder.AddCompositeConstruct(widenedTypeId, componentIds); return widened != nullptr ? widened->result_id() : 0u; }; for (RewritePlan::Site& site : plan.sites) { Instruction* instruction = site.instruction; struct Target { uint32_t position; bool mustBeConstant; }; const Target targets[] = { {site.coordinateOperand, false}, {site.operands.gradX, false}, {site.operands.gradY, false}, {site.operands.offset, false}, {site.operands.constOffset, true}, }; for (const Target& target : targets) { // Position 0 is the image operand, so it is this plan's "absent" marker // for everything except the coordinate, which is never 0. if (target.position == 0u) continue; const uint32_t widenedId = widen(instruction->GetSingleWordInOperand(target.position), instruction, target.mustBeConstant); if (widenedId == 0u) { // Reachable only if the module's shapes disagree with what the plan // recorded. Failing here makes the caller keep the input binary, // which is the same outcome as a decline. return Status::Failure; } instruction->SetInOperand(target.position, {widenedId}); } irContext->UpdateDefUse(instruction); } // Only now, with no lookup still spelling a 1D coordinate, does the type become // the 2D one - which is what ES stores a GL_TEXTURE_1D(_ARRAY) as anyway // (MapToBackendTextureTarget), and what SPIRV-Cross was already PRINTING for it. for (Instruction& type : irContext->types_values()) { if (scope.Covers(&type)) { type.SetInOperand(kDimOperand, {static_cast(spv::Dim::Dim2D)}); } } // Sampled1D describes the types just rewritten. Drop it only if no 1D SAMPLED // image is left at all - a module may still hold one this pass left alone (a // category with no offset or gradient on it), and that one still needs the // capability. Image1D is deliberately untouched: it belongs to the storage images // Lower1DArrayImagesPass owns, and they may still be Dim1D here. Shader is // declared by any module reaching this point, so restating it keeps the // instruction valid and RemoveDuplicates collapses the pair. if (!AnyDim1DSampledTypeLeft(irContext)) { for (Instruction& capability : irContext->capabilities()) { const auto value = static_cast(capability.GetSingleWordInOperand(0)); if (value == spv::Capability::Sampled1D) { capability.SetInOperand(0, {static_cast(spv::Capability::Shader)}); } } } irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); return Status::SuccessWithChange; } spvtools::Optimizer::PassToken Lower1DSampledImagesPass::CreateLower1DSampledImagesPass() { return spvtools::Optimizer::PassToken( spvtools::MakeUnique()); } } // namespace ShaderTranspiler } // namespace MG_Util } // namespace MobileGL