diff --git a/CMakeLists.txt b/CMakeLists.txt index e728087e..7ab628c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -284,6 +284,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 78215b41..ba4ce5d6 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -1313,10 +1313,17 @@ namespace MobileGL::MG_Backend::DirectGLES { void SyncNeccessaryTextures() { SyncNeccessaryTextures(CaptureDrawTextureSyncKeys()); } + // Whether glBindImageTexture's `layered` means anything for this target - asked of the + // target the DRIVER will see, not the one the application named. A GL_TEXTURE_1D_ARRAY + // is stored as an ES 2D array (MapToBackendTextureTarget), and so is layerable; asking + // the state target instead answered "no" for it and pinned every 1D-array image binding + // to layer 0, whatever the application passed. static Bool SupportsLayeredImageBinding(TextureTarget target) { - return target == TextureTarget::Texture3D || target == TextureTarget::TextureCubeMap || - target == TextureTarget::Texture2DArray || target == TextureTarget::TextureCubeMapArray || - target == TextureTarget::Texture2DMultisampleArray; + const TextureTarget backendTarget = TextureImpl::MapToBackendTextureTarget(target); + return backendTarget == TextureTarget::Texture3D || backendTarget == TextureTarget::TextureCubeMap || + backendTarget == TextureTarget::Texture2DArray || + backendTarget == TextureTarget::TextureCubeMapArray || + backendTarget == TextureTarget::Texture2DMultisampleArray; } void SyncImageTextureBinding(Uint unit) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 607c11ba..56bcb119 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -4705,6 +4705,20 @@ namespace MobileGL::MG_Backend::DirectGLES { effectiveSpirv = &rectLoweredSpirv; } + // ES has no 1D texture at all, so a 1D ARRAY is stored as a 2D array with height + // 1 (MapToBackendTextureTarget / GetBackendUploadSize). SPIRV-Cross emulates 1D + // as 2D for images without ever asking whether the type is arrayed, so a + // 1D-array image comes out as ivec2(ivec2(u, layer), 0) - three components in a + // two-component constructor, which every driver rejects, taking the whole + // program with it. The pass does the conversion properly - type to 2D array, + // coordinate to (u, 0, layer) - before SPIRV-Cross can apply its own. + Vector arrayImageSpirv; + if (MG_Util::ShaderTranspiler::ShaderCompiler::Lower1DArrayImagesForEssl(*effectiveSpirv, + arrayImageSpirv) && + !arrayImageSpirv.empty()) { + effectiveSpirv = &arrayImageSpirv; + } + // 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 diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index 67e47d1e..0806f93e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -1721,6 +1721,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { return ProgramFactory::DescriptorBindingKind::CombinedImageSampler; case SPV_REFLECT_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER: return ProgramFactory::DescriptorBindingKind::UniformTexelBuffer; + case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER: + return ProgramFactory::DescriptorBindingKind::StorageTexelBuffer; case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_BUFFER: return ProgramFactory::DescriptorBindingKind::StorageBuffer; case SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_IMAGE: @@ -1750,6 +1752,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } if (kind == ProgramFactory::DescriptorBindingKind::CombinedImageSampler || kind == ProgramFactory::DescriptorBindingKind::UniformTexelBuffer || + kind == ProgramFactory::DescriptorBindingKind::StorageTexelBuffer || kind == ProgramFactory::DescriptorBindingKind::StorageImage) { const auto arraySuffix = name.find("[0]"); if (arraySuffix != String::npos) { @@ -1839,8 +1842,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // UniformManager::BindProgramUniformBuffers: UBO instance arrays // (uniform Block {...} b[N];), storage-block instance arrays, image uniform // arrays, and combined-image-sampler arrays (uniform sampler2D s[N];). - // Anything else - a uniform TEXEL buffer array is the one remaining kind - - // must fail program creation cleanly rather than continue with corrupt state. + // Anything else - the two TEXEL buffer kinds are what remain, samplerBuffer[N] + // and imageBuffer[N] - must fail program creation cleanly rather than continue + // with corrupt state. Their per-draw path writes pTexelBufferView as the + // address of a vector element sized for one descriptor per binding, so an + // array would not merely be unresolved, it would dangle. // // Getting listed here is not cosmetic: a kind that is rejected leaves // GetOrCreateProgram's MOBILEGL_ASSERT(remapOk) as the only complaint, and @@ -2661,6 +2667,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const auto descriptorKind = ReflectDescriptorTypeToBindingKind(sampler->descriptor_type); if (descriptorKind != DescriptorBindingKind::CombinedImageSampler && descriptorKind != DescriptorBindingKind::UniformTexelBuffer && + descriptorKind != DescriptorBindingKind::StorageTexelBuffer && descriptorKind != DescriptorBindingKind::StorageImage && descriptorKind != DescriptorBindingKind::StorageBuffer) { continue; @@ -2790,6 +2797,29 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + if (descriptorKind == DescriptorBindingKind::StorageTexelBuffer) { + // Only the declared format is recorded, and only so the per-draw resolve can + // prefer it over the one glBindImageTexture named. Everything the StorageImage + // branch above does about ARRAYS is deliberately absent: an imageBuffer array + // is refused outright by the array gate in RemapDescriptorBindingsForVulkan, + // exactly as a samplerBuffer array is, so bindingDescriptorCounts stays at the + // default 1 and the descriptor write below may take the address of a vector + // element without reserving room for extra elements. + const VkFormat reflectedFormat = + ConvertSpirvImageFormatToVkFormat(sampler->image.image_format); + VkFormat& existingFormat = entry.storageImageFormatByBinding[binding]; + MOBILEGL_ASSERT(existingFormat == VK_FORMAT_UNDEFINED || + reflectedFormat == VK_FORMAT_UNDEFINED || + existingFormat == reflectedFormat, + "ProgramFactory::ReflectLayout: storage texel buffer binding %u ('%s') " + "has conflicting reflected formats (%d vs %d)", + binding, uniformName.c_str(), static_cast(existingFormat), + static_cast(reflectedFormat)); + if (existingFormat == VK_FORMAT_UNDEFINED) { + existingFormat = reflectedFormat; + } + } + const TextureTarget target = UniformTypeToTextureTarget(uniformType); MOBILEGL_ASSERT(target != TextureTarget::Unknown, "ProgramFactory::ReflectLayout: failed to resolve texture target for '%s'", @@ -2867,6 +2897,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { entry.dynamicBindings.push_back(binding); } else if (kind == DescriptorBindingKind::UniformTexelBuffer) { layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER; + } else if (kind == DescriptorBindingKind::StorageTexelBuffer) { + layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER; } else if (kind == DescriptorBindingKind::StorageBuffer) { layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; } else if (kind == DescriptorBindingKind::StorageImage) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index 43f82fb2..2dcca478 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -33,7 +33,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { CombinedImageSampler, UniformTexelBuffer, StorageBuffer, - StorageImage + StorageImage, + // GLSL `imageBuffer` - a buffer texture reached through an IMAGE unit rather than a + // texture unit. Vulkan spells it VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, which is a + // VkBufferView like UniformTexelBuffer and not a VkImageView like StorageImage: it is + // the one image uniform whose descriptor is a buffer. Appended, never inserted - + // DescriptorKeyHash mixes the enumerator's value. + StorageTexelBuffer }; enum class CompileOptionBit : Uint { @@ -103,6 +109,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector samplerUniformLocationByBinding; Vector samplerTextureTargetByBinding; Vector samplerNumericDomainByBinding; + // Shared by StorageImage and StorageTexelBuffer bindings: a binding is one kind or + // the other, never both, and both need exactly the same thing - the format the + // shader declared, so the per-draw resolve can tell a typed declaration from a + // formatless one. Kept as one pair rather than two so the move operations below + // cannot drift out of sync with a field that only one kind populates. Vector storageImageFormatByBinding; Vector storageImageUsesBindingFormatByBinding; Vector storageBlockNameByBinding; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 61590f0e..6ecbc884 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -743,6 +743,142 @@ namespace MobileGL::MG_Backend::DirectVulkan { return true; } + // GLSL `imageBuffer`. The one image uniform whose Vulkan descriptor is a VkBufferView rather + // than a VkImageView, so it is half ResolveStorageImageDescriptor (the resource comes from an + // IMAGE unit, i.e. from glBindImageTexture, not from a texture unit) and half + // ResolveTexelBufferDescriptor (the descriptor is a buffer view over the GL buffer the + // texture is attached to). + // + // Before this existed the descriptor kind reflected as SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_- + // TEXEL_BUFFER and fell into ReflectDescriptorTypeToBindingKind's `default:`, whose only + // complaint is an assert that compiles out above DEBUG - so a release build declared no + // binding at all for a uniform the shader still read, and lavapipe segfaulted inside pipeline + // creation on the JIT worker thread. KHR-GL44.multi_bind.dispatch_bind_image_textures is the + // case that carries it. + Bool UniformManager::ResolveStorageTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, + Uint32 binding, Uint32 frameIndex, + VkBufferView& outBufferView) { + outBufferView = VK_NULL_HANDLE; + MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageTexelBufferDescriptor: buffer manager is null"); + MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageTexelBufferDescriptor: GL context is null"); + MOBILEGL_ASSERT(frameIndex < m_frames.size(), + "ResolveStorageTexelBufferDescriptor: frame index out of range"); + MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), + "ResolveStorageTexelBufferDescriptor: binding %u out of range", binding); + + const Int location = programObj.samplerUniformLocationByBinding[binding]; + if (location < 0) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: binding %u ('%s') has no uniform location", binding, + programObj.samplerNameByBinding[binding].c_str()); + return false; + } + const Int imageUnit = program.GetUniformSamplerOrImageUnitIndex(static_cast(location)); + if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: image unit %d out of range for binding %u", imageUnit, + binding); + return false; + } + + auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + const auto& texture = imageBinding.Texture; + if (texture == nullptr) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: image unit %d is unbound for binding %u", imageUnit, + binding); + return false; + } + if (texture->GetStorageType() != TextureStorageType::Buffer || + texture->GetTarget() != TextureTarget::TextureBuffer) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: binding %u ('%s') expected a texture buffer on image " + "unit %d, got textureId=%u target=%d storage=%d", + binding, programObj.samplerNameByBinding[binding].c_str(), imageUnit, + texture->GetExternalIndex(), static_cast(texture->GetTarget()), + static_cast(texture->GetStorageType())); + return false; + } + + auto* textureBuffer = static_cast(texture.get()); + const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject(); + if (bufferObject == nullptr) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: texture buffer on image unit %d has no GL buffer bound", + imageUnit); + return false; + } + + // Unlike the sampled texel buffer, the shader may WRITE this one, and those writes land in + // GPU memory behind the frontend's CPU shadow - which is what MapBuffer and + // GetBufferSubData read. Same two calls, and for the same reason, as the storage-block + // path above. + bufferObject->EnsureGpuResidentStorage(); + bufferObject->MarkGpuWritten(); + + BufferSlice slice{}; + if (!m_bufferManager->AcquireResidentSlice(BufferKind::TextureBuffer, bufferObject, slice) || + !slice.IsValid()) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: failed to sync GL buffer %u for texture buffer %u", + bufferObject->GetExternalIndex(), texture->GetExternalIndex()); + return false; + } + + // The format the SHADER declared wins over the one glBindImageTexture named, on the same + // policy as a storage image: a typed `layout(r32ui) uniform uimageBuffer` must be read as + // r32ui whatever the texture's own attachment format says. Falling back, in order: + // reflected format, then the bind format, then the texture's attached format. + MOBILEGL_ASSERT(binding < programObj.storageImageFormatByBinding.size(), + "ResolveStorageTexelBufferDescriptor: binding %u has no reflected format slot", binding); + const auto internalFormat = textureBuffer->GetFormat(); + const VkFormat resourceFormat = MG_Util::ConvertTextureInternalFormatToVkEnum(internalFormat); + const VkFormat reflectedFormat = programObj.storageImageFormatByBinding[binding]; + VkFormat vkFormat = reflectedFormat; + if (vkFormat == VK_FORMAT_UNDEFINED && imageBinding.Format != 0) { + vkFormat = MG_Util::ConvertTextureInternalFormatToVkEnum( + MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format)); + } + if (vkFormat == VK_FORMAT_UNDEFINED) { + vkFormat = resourceFormat; + } + if (vkFormat == VK_FORMAT_UNDEFINED) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: unsupported image buffer format (internal=%d bind=0x%x)", + static_cast(internalFormat), imageBinding.Format); + return false; + } + + // Sized against the VIEW's format, not the texture's attached one - they may differ by the + // paragraph above, and a range that is not a whole number of the view's texels is invalid. + const VkDeviceSize texelSize = + static_cast(MG_Util::GetSizedInternalFormatSizeInBytes(internalFormat)); + const VkDeviceSize rangeOffset = static_cast(textureBuffer->GetBufferRangeOffset()); + const VkDeviceSize rangeSize = static_cast(textureBuffer->GetBufferRangeSizeInBytes()); + VkDeviceSize viewRange = std::min(rangeSize, slice.size > rangeOffset ? slice.size - rangeOffset : 0); + if (texelSize > 0) { + viewRange = (viewRange / texelSize) * texelSize; + } + if (viewRange == 0) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: texture buffer %u has empty view range", + texture->GetExternalIndex()); + return false; + } + + VkBufferViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO; + viewInfo.buffer = slice.buffer; + viewInfo.format = vkFormat; + viewInfo.offset = slice.offset + rangeOffset; + viewInfo.range = viewRange; + + VkBufferView bufferView = VK_NULL_HANDLE; + const VkResult result = vkCreateBufferView(m_device, &viewInfo, nullptr, &bufferView); + if (result != VK_SUCCESS || bufferView == VK_NULL_HANDLE) { + MGLOG_E("ResolveStorageTexelBufferDescriptor: vkCreateBufferView failed result=%d format=%d range=%zu", + result, static_cast(vkFormat), static_cast(viewRange)); + return false; + } + + m_frames[frameIndex].texelBufferViews.push_back(bufferView); + outBufferView = bufferView; + return true; + } + Bool UniformManager::ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element, @@ -1267,7 +1403,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } const Uint32 descriptorCount = static_cast(descriptorCount64); - VkDescriptorPoolSize poolSizes[5]{}; + VkDescriptorPoolSize poolSizes[6]{}; poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC; poolSizes[0].descriptorCount = descriptorCount; poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; @@ -1278,6 +1414,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { poolSizes[3].descriptorCount = descriptorCount; poolSizes[4].type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; poolSizes[4].descriptorCount = descriptorCount; + poolSizes[5].type = VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER; + poolSizes[5].descriptorCount = descriptorCount; VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; @@ -1644,6 +1782,25 @@ namespace MobileGL::MG_Backend::DirectVulkan { write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER; write.pTexelBufferView = &texelBufferViews.back(); writes.push_back(write); + } else if (kind == ProgramFactory::DescriptorBindingKind::StorageTexelBuffer) { + // Shares texelBufferViews with the sampled kind above, and may do so safely for + // the same reason: neither kind can be an array, so each contributes exactly one + // element and the reserve of m_maxBindings cannot be outrun - which is what keeps + // the &back() below from dangling when a later binding pushes. + VkBufferView bufferView = VK_NULL_HANDLE; + if (!ResolveStorageTexelBufferDescriptor(program, programObj, binding, frameIndex, bufferView) || + bufferView == VK_NULL_HANDLE) { + MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: image buffer binding %u " + "has no valid descriptor", + binding); + return false; + } + + texelBufferViews.push_back(bufferView); + fastRebindKindsEligible = false; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER; + write.pTexelBufferView = &texelBufferViews.back(); + writes.push_back(write); } else if (kind == ProgramFactory::DescriptorBindingKind::StorageBuffer) { // One write per binding, but `descriptorCount` buffer infos: a GLSL block // instance array occupies a single binding whose elements each come from their diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index 21233772..a0053d0b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -175,6 +175,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 frameIndex, VkBufferView& outBufferView); + // GLSL `imageBuffer`: the same VkBufferView descriptor as the sampled texel buffer above, + // but resolved from an IMAGE unit (glBindImageTexture) rather than a texture unit, and + // made GPU-resident-writable because the shader may store to it. No `element` parameter: + // an imageBuffer ARRAY is refused at program creation, so a binding is always one + // descriptor (see the array gate in RemapDescriptorBindingsForVulkan). + Bool ResolveStorageTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, Uint32 binding, + Uint32 frameIndex, VkBufferView& outBufferView); // `element` indexes a block INSTANCE array's descriptors; it is 0 for every ordinary // block. Each element resolves through its own GL storage block, and so its own GL // binding point, buffer and glBindBufferRange window. diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index c579c612..da1d9d68 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -23,7 +23,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | - VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + // "Every usage" has to mean every usage: a buffer texture reached through an IMAGE + // unit takes a VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER descriptor, and the write is + // invalid unless the buffer was created with this bit. Nothing asked for it until + // imageBuffer support existed, so the omission was invisible. + VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT; // Appended to kPersistentBackedUsage when VK_EXT_transform_feedback is enabled // (see VkBufferManagerInitInfo::transformFeedbackUsageEnabled). constexpr VkBufferUsageFlags kTransformFeedbackUsage = @@ -714,7 +718,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { case BufferKind::Uniform: return VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; case BufferKind::TextureBuffer: - return VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT; + // Both texel roles, for the same reason vertex/index carry both bits: one GL buffer + // texture can be read as a samplerBuffer and written as an imageBuffer, and which of + // the two it is only becomes known when a shader that uses it is bound - long after + // the resident buffer was created. A VkBufferView for a storage-texel descriptor is + // invalid unless the buffer was created with the storage bit, so a buffer that + // acquired only the uniform bit could never be given one. + return VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT; case BufferKind::ShaderStorage: return VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT; case BufferKind::Indirect: diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 9da8b4d8..d33d9dd5 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -69,6 +69,7 @@ add_executable(MobileGLIntegrationTest Scenarios/SwizzleAccessRoutineScenario.cpp Scenarios/ProgramPipelineScenario.cpp Scenarios/ImageLoadStoreSsoScenario.cpp + Scenarios/ImageTargetKindScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp Scenarios/FragmentOutputArrayIndexScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageTargetKindScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageTargetKindScenario.cpp new file mode 100644 index 00000000..10563ae1 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageTargetKindScenario.cpp @@ -0,0 +1,514 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ImageTargetKindScenario.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 +// +// Scenario - ONE IMAGE TARGET KIND AT A TIME, THROUGH A COMPUTE DISPATCH. +// +// KHR-GL44.multi_bind.dispatch_bind_image_textures decomposed. That conformance case declares +// ELEVEN image uniforms of eleven different target kinds in one compute shader, binds a texture +// of the matching kind to each unit, sums one texel from every one of them and compares the sum +// against N*(N-1)/2. It is a single pass/fail bit over eleven independent mechanisms: if any one +// of them is wrong - or merely fails to compile - the case fails and says nothing about which. +// That is what it did here, on both backends, for two waves. +// +// So the eleven are pulled apart into one case each. Each case declares ONE image uniform, binds +// ONE texture and checks the value that comes back, so a failure names the target kind and the +// direction. What the conformance case does with eleven at once, AllKindsInOneProgram at the +// bottom still does - a defect that only appears when several kinds share a program is invisible +// to the single-kind cases by construction. +// +// The shape is deliberately the conformance case's own, not a cleaner equivalent: +// +// * r32ui / GL_R32UI throughout, 6x6x6 storage, one level, texel (0,0,0) read; +// * `layout (location = N, r32ui) readonly uniform` - an explicit uniform LOCATION, not a +// binding, with the image unit then assigned by glUniform1i. That combination is the one ES +// cannot express directly, because ES forbids glUniform1i on an image uniform and the unit +// has to be baked into the generated ESSL (RebindImageUniformsToFrontendUnits); +// * `layout (std140, ...) buffer` for the result block - legal, but unusual enough that a +// frontend could plausibly mishandle it. Mirroring it means a green scenario cannot be green +// for a reason the conformance case excludes; +// * glBindImageTexture with layered = GL_TRUE, which is what glBindImageTextures is specified +// to pass, and which is where a target kind whose layeredness a backend does not recognise +// goes wrong. +// +// MULTISAMPLE is the one kind that is not merely an emulation problem, and the conformance case +// already knows it: it reads GL_MAX_IMAGE_SAMPLES and, when that is zero, substitutes a plain 2D +// texture and a plain uimage2D for both multisample entries. MobileGL reports zero, so the +// conformance case never asks it for a multisample image at all. The two cases below are kept +// and skip on that same query, so the coverage is already written the day a backend advertises +// them - and so the skip is a standing record of WHY the conformance case passes without them. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // The conformance case's own dimensions: one level, 6 on every axis (which is also + // exactly one cube's worth for a cube array), and a single texel read at the origin. + constexpr int kExtent = 6; + constexpr GLuint kFilledValue = 7u; + constexpr GLuint kStoredValue = 13u; + + // Everything that differs between the eleven kinds, in one row. + struct TargetKind { + const char* name; // this scenario's name for it, which failure messages carry + GLenum target; // the GL texture target + const char* imageType; // the GLSL image uniform type + const char* coord; // the coordinate expression imageLoad/imageStore takes + bool multisample; // needs GL_MAX_IMAGE_SAMPLES > 0 + bool buffer; // storage comes from a buffer object, not TexStorage + }; + + constexpr TargetKind kKind1D{"1D", GL_TEXTURE_1D, "uimage1D", "0", false, false}; + constexpr TargetKind kKind1DArray{"1DArray", GL_TEXTURE_1D_ARRAY, "uimage1DArray", "ivec2(0, 0)", false, + false}; + constexpr TargetKind kKind2D{"2D", GL_TEXTURE_2D, "uimage2D", "ivec2(0, 0)", false, false}; + constexpr TargetKind kKind2DArray{"2DArray", GL_TEXTURE_2D_ARRAY, "uimage2DArray", "ivec3(0, 0, 0)", false, + false}; + constexpr TargetKind kKind3D{"3D", GL_TEXTURE_3D, "uimage3D", "ivec3(0, 0, 0)", false, false}; + constexpr TargetKind kKindBuffer{"Buffer", GL_TEXTURE_BUFFER, "uimageBuffer", "0", false, true}; + constexpr TargetKind kKindCube{"Cube", GL_TEXTURE_CUBE_MAP, "uimageCube", "ivec3(0, 0, 0)", false, false}; + constexpr TargetKind kKindCubeArray{"CubeArray", GL_TEXTURE_CUBE_MAP_ARRAY, "uimageCubeArray", + "ivec3(0, 0, 0)", false, false}; + constexpr TargetKind kKindRect{"Rect", GL_TEXTURE_RECTANGLE, "uimage2DRect", "ivec2(0, 0)", false, false}; + constexpr TargetKind kKind2DMS{"2DMS", GL_TEXTURE_2D_MULTISAMPLE, "uimage2DMS", "ivec2(0, 0)", true, false}; + constexpr TargetKind kKind2DMSArray{"2DMSArray", GL_TEXTURE_2D_MULTISAMPLE_ARRAY, "uimage2DMSArray", + "ivec3(0, 0, 0)", true, false}; + + // A multisample image load/store takes the sample index as an extra argument; no other + // kind does. Keeping that in one place stops the two spellings drifting apart. + std::string LoadExpression(const TargetKind& kind, const std::string& name) { + return "imageLoad(" + name + ", " + kind.coord + (kind.multisample ? ", 0)" : ")"); + } + + std::string StoreStatement(const TargetKind& kind, const std::string& name, const char* value) { + return "imageStore(" + name + ", " + kind.coord + (kind.multisample ? ", 0, uvec4(" : ", uvec4(") + + value + ", 0, 0, 0));"; + } + + const char* kComputePrologue = "#version 440 core\n" + "\n" + "layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;\n" + "\n"; + + const char* kResultBlock = "layout (std140, binding = 0) buffer SSB {\n" + " uint sum;\n" + "} ssb;\n" + "\n"; + + // The conformance case's shader, narrowed to a single image. + std::string SingleLoadSource(const TargetKind& kind) { + return std::string(kComputePrologue) + "layout (location = 0, r32ui) readonly uniform " + kind.imageType + + " i0;\n" + kResultBlock + "void main()\n{\n uvec4 v = " + LoadExpression(kind, "i0") + + ";\n ssb.sum = v.r;\n}\n"; + } + + // The other direction. Written as its own program rather than a read-write one so that a + // backend which gets the store right and the load wrong (or the reverse) is not able to + // cancel its own defect out. + std::string SingleStoreSource(const TargetKind& kind) { + return std::string(kComputePrologue) + "layout (location = 0, r32ui) writeonly uniform " + + kind.imageType + " i0;\n\nvoid main()\n{\n " + StoreStatement(kind, "i0", "13u") + "\n}\n"; + } + + class ImageTargetKindScenario : public ScenarioTest { + protected: + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + for (GLuint t : m_textures) glDeleteTextures(1, &t); + for (GLuint b : m_buffers) glDeleteBuffers(1, &b); + m_programs.clear(); + m_textures.clear(); + m_buffers.clear(); + // Leave no image unit bound. These scenarios share one context, and a stale image + // binding is exactly the kind of state that makes the NEXT scenario's failure + // impossible to reproduce on its own. + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + for (GLint unit = 0; unit < maxImageUnits; ++unit) { + glBindImageTexture(static_cast(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI); + } + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0); + while (glGetError() != GL_NO_ERROR) { + } + } + + bool ImagesAreUsable() const { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + GLint maxComputeImageUniforms = 0; + glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms); + while (glGetError() != GL_NO_ERROR) { + } + return maxImageUnits >= 1 && maxComputeImageUniforms >= 1; + } + + // The conformance case's own multisample gate, asked the same way it asks it. + bool MultisampleImagesAreUsable() const { + GLint maxImageSamples = 0; + glGetIntegerv(GL_MAX_IMAGE_SAMPLES, &maxImageSamples); + while (glGetError() != GL_NO_ERROR) { + } + return maxImageSamples > 0; + } + + GLuint MakeComputeProgram(const std::string& source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + const char* text = source.c_str(); + glShaderSource(shader, 1, &text, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[4096] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute shader did not compile: " << log << "\nsource:\n" << source; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + m_programs.push_back(program); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[4096] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute program did not link: " << log << "\nsource:\n" << source; + return 0; + } + return program; + } + + // Storage plus a full fill with `value`, in the spelling each target kind needs. + // Returns 0 - having already reported - when the target could not be created. + GLuint MakeTexture(const TargetKind& kind, bool fill) { + const std::vector texels(static_cast(kExtent) * kExtent * kExtent, kFilledValue); + + if (kind.buffer) { + GLuint buffer = 0; + glGenBuffers(1, &buffer); + m_buffers.push_back(buffer); + glBindBuffer(GL_TEXTURE_BUFFER, buffer); + glBufferData(GL_TEXTURE_BUFFER, static_cast(texels.size() * sizeof(GLuint)), + fill ? texels.data() : nullptr, GL_DYNAMIC_COPY); + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + glBindTexture(GL_TEXTURE_BUFFER, texture); + glTexBuffer(GL_TEXTURE_BUFFER, GL_R32UI, buffer); + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << kind.name << ": creating the texture buffer errored with " + << GLErrorName(error); + return 0; + } + return texture; + } + + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + glBindTexture(kind.target, texture); + + switch (kind.target) { + case GL_TEXTURE_1D: + glTexStorage1D(kind.target, 1, GL_R32UI, kExtent); + break; + case GL_TEXTURE_2D: + case GL_TEXTURE_RECTANGLE: + case GL_TEXTURE_1D_ARRAY: + case GL_TEXTURE_CUBE_MAP: + glTexStorage2D(kind.target, 1, GL_R32UI, kExtent, kExtent); + break; + case GL_TEXTURE_2D_ARRAY: + case GL_TEXTURE_3D: + case GL_TEXTURE_CUBE_MAP_ARRAY: + glTexStorage3D(kind.target, 1, GL_R32UI, kExtent, kExtent, kExtent); + break; + case GL_TEXTURE_2D_MULTISAMPLE: + glTexStorage2DMultisample(kind.target, 1, GL_R32UI, kExtent, kExtent, GL_FALSE); + break; + case GL_TEXTURE_2D_MULTISAMPLE_ARRAY: + glTexStorage3DMultisample(kind.target, 1, GL_R32UI, kExtent, kExtent, kExtent, GL_FALSE); + break; + default: + ADD_FAILURE() << kind.name << ": no storage spelling for target 0x" << std::hex << kind.target; + return 0; + } + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << kind.name << ": allocating storage errored with " << GLErrorName(error); + return 0; + } + + // A multisample texture has no TexSubImage - the conformance case fills it with a + // compute pass, which is what the store cases below do. + if (!fill || kind.multisample) return texture; + + switch (kind.target) { + case GL_TEXTURE_1D: + glTexSubImage1D(kind.target, 0, 0, kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data()); + break; + case GL_TEXTURE_2D: + case GL_TEXTURE_RECTANGLE: + case GL_TEXTURE_1D_ARRAY: + glTexSubImage2D(kind.target, 0, 0, 0, kExtent, kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT, + texels.data()); + break; + case GL_TEXTURE_CUBE_MAP: + for (int face = 0; face < 6; ++face) { + glTexSubImage2D(static_cast(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face), 0, 0, 0, kExtent, + kExtent, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data()); + } + break; + case GL_TEXTURE_2D_ARRAY: + case GL_TEXTURE_3D: + case GL_TEXTURE_CUBE_MAP_ARRAY: + glTexSubImage3D(kind.target, 0, 0, 0, 0, kExtent, kExtent, kExtent, GL_RED_INTEGER, + GL_UNSIGNED_INT, texels.data()); + break; + default: + break; + } + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << kind.name << ": uploading texels errored with " << GLErrorName(error); + return 0; + } + return texture; + } + + // A 4-byte `buffer` block bound to base 0, which is where every case puts its answer. + GLuint MakeResultBuffer() { + GLuint ssbo = 0; + glGenBuffers(1, &ssbo); + m_buffers.push_back(ssbo); + const GLuint zero = 0u; + glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo); + glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(GLuint), &zero, GL_DYNAMIC_COPY); + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo); + return ssbo; + } + + GLuint ReadResult(GLuint ssbo) { + glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo); + GLuint value = 0xFFFFFFFFu; + glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(GLuint), &value); + return value; + } + + // Fill a texture of `kind`, read texel (0,0,0) of it through an image uniform in a + // compute dispatch, and require the value back. + void RunLoadCase(const TargetKind& kind) { + const GLuint program = MakeComputeProgram(SingleLoadSource(kind)); + if (program == 0) return; + const GLuint texture = MakeTexture(kind, true); + if (texture == 0) return; + const GLuint ssbo = MakeResultBuffer(); + + glBindImageTexture(0, texture, 0, GL_TRUE, 0, GL_READ_ONLY, GL_R32UI); + ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": glBindImageTexture errored"; + + glUseProgram(program); + // The unit, by LOCATION - the conformance case's own redundant-but-legal + // assignment, and the one ES cannot take at the API level. + glUniform1i(0, 0); + ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": assigning the image unit errored"; + + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the dispatch leaked a GL error"; + + EXPECT_EQ(ReadResult(ssbo), kFilledValue) + << kind.name << ": the compute dispatch did not read the value the texture was filled with"; + glUseProgram(0); + } + + // The other direction: store through an image uniform, then read the same texel back + // through a SECOND program, so a defect cannot cancel itself out. + void RunStoreCase(const TargetKind& kind) { + const GLuint storeProgram = MakeComputeProgram(SingleStoreSource(kind)); + const GLuint loadProgram = MakeComputeProgram(SingleLoadSource(kind)); + if (storeProgram == 0 || loadProgram == 0) return; + const GLuint texture = MakeTexture(kind, false); + if (texture == 0) return; + const GLuint ssbo = MakeResultBuffer(); + + glBindImageTexture(0, texture, 0, GL_TRUE, 0, GL_READ_WRITE, GL_R32UI); + ASSERT_EQ(FirstGLError(), 0u) << kind.name << ": glBindImageTexture errored"; + + glUseProgram(storeProgram); + glUniform1i(0, 0); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the storing dispatch leaked a GL error"; + + glUseProgram(loadProgram); + glUniform1i(0, 0); + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << kind.name << ": the loading dispatch leaked a GL error"; + + EXPECT_EQ(ReadResult(ssbo), kStoredValue) + << kind.name << ": the value stored through the image did not come back"; + glUseProgram(0); + } + + std::vector m_programs; + std::vector m_textures; + std::vector m_buffers; + }; + + } // namespace + + // ---- the load direction, one target kind per case ----------------------- + // + // Exactly what the conformance case does with each of its eleven uniforms, but alone, so a + // failure names the kind. + +#define MGL_DEFINE_LOAD_CASE(CaseName, Kind) \ + TEST_F(ImageTargetKindScenario, Loads##CaseName) { \ + if (!Ready()) return; \ + if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; \ + if ((Kind).multisample && !MultisampleImagesAreUsable()) { \ + GTEST_SKIP() << "GL_MAX_IMAGE_SAMPLES is 0, so the conformance case substitutes a plain 2D image " \ + "here and never asks for a multisample one"; \ + } \ + RunLoadCase(Kind); \ + } + +#define MGL_DEFINE_STORE_CASE(CaseName, Kind) \ + TEST_F(ImageTargetKindScenario, Stores##CaseName) { \ + if (!Ready()) return; \ + if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; \ + if ((Kind).multisample && !MultisampleImagesAreUsable()) { \ + GTEST_SKIP() << "GL_MAX_IMAGE_SAMPLES is 0, so the conformance case substitutes a plain 2D image " \ + "here and never asks for a multisample one"; \ + } \ + RunStoreCase(Kind); \ + } + + MGL_DEFINE_LOAD_CASE(Texture1D, kKind1D) + MGL_DEFINE_LOAD_CASE(Texture1DArray, kKind1DArray) + MGL_DEFINE_LOAD_CASE(Texture2D, kKind2D) + MGL_DEFINE_LOAD_CASE(Texture2DArray, kKind2DArray) + MGL_DEFINE_LOAD_CASE(Texture3D, kKind3D) + MGL_DEFINE_LOAD_CASE(TextureBuffer, kKindBuffer) + MGL_DEFINE_LOAD_CASE(TextureCube, kKindCube) + MGL_DEFINE_LOAD_CASE(TextureCubeArray, kKindCubeArray) + MGL_DEFINE_LOAD_CASE(TextureRectangle, kKindRect) + MGL_DEFINE_LOAD_CASE(Texture2DMultisample, kKind2DMS) + MGL_DEFINE_LOAD_CASE(Texture2DMultisampleArray, kKind2DMSArray) + + MGL_DEFINE_STORE_CASE(Texture1D, kKind1D) + MGL_DEFINE_STORE_CASE(Texture1DArray, kKind1DArray) + MGL_DEFINE_STORE_CASE(Texture2D, kKind2D) + MGL_DEFINE_STORE_CASE(Texture2DArray, kKind2DArray) + MGL_DEFINE_STORE_CASE(Texture3D, kKind3D) + MGL_DEFINE_STORE_CASE(TextureBuffer, kKindBuffer) + MGL_DEFINE_STORE_CASE(TextureCube, kKindCube) + MGL_DEFINE_STORE_CASE(TextureCubeArray, kKindCubeArray) + MGL_DEFINE_STORE_CASE(TextureRectangle, kKindRect) + MGL_DEFINE_STORE_CASE(Texture2DMultisample, kKind2DMS) + MGL_DEFINE_STORE_CASE(Texture2DMultisampleArray, kKind2DMSArray) + +#undef MGL_DEFINE_LOAD_CASE +#undef MGL_DEFINE_STORE_CASE + + // ---- and all of them at once ------------------------------------------- + // + // The conformance case's actual shape. The single-kind cases above cannot see a defect that + // needs several kinds in one program - a binding remap that only collides when two image + // types share a descriptor set, a per-kind rewrite that is not idempotent across declarations + // - and that class of defect is precisely what "each kind passes alone but the case still + // fails" would mean. Each unit is filled with its own INDEX rather than a constant, so the + // sum names how many units contributed and a single mis-bound unit does not cancel out. + TEST_F(ImageTargetKindScenario, AllKindsInOneProgram) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; + + const bool multisample = MultisampleImagesAreUsable(); + std::vector kinds{kKind1D, kKind1DArray, kKind2D, kKind2DArray, + kKind3D, kKindBuffer, kKindCube, kKindCubeArray, + kKindRect}; + if (multisample) { + kinds.push_back(kKind2DMS); + kinds.push_back(kKind2DMSArray); + } + + GLint maxComputeImageUniforms = 0; + glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms); + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + while (glGetError() != GL_NO_ERROR) { + } + const std::size_t count = + std::min(kinds.size(), static_cast(std::max(0, std::min(maxComputeImageUniforms, + maxImageUnits)))); + if (count == 0) GTEST_SKIP() << "no image units"; + kinds.resize(count); + + std::string declarations; + std::string sum; + for (std::size_t i = 0; i < kinds.size(); ++i) { + const std::string name = "i" + std::to_string(i); + declarations += "layout (location = " + std::to_string(i) + ", r32ui) readonly uniform " + + kinds[i].imageType + " " + name + ";\n"; + if (!sum.empty()) sum += " + "; + sum += LoadExpression(kinds[i], name); + } + const std::string source = std::string(kComputePrologue) + declarations + kResultBlock + + "void main()\n{\n uvec4 v = " + sum + ";\n ssb.sum = v.r;\n}\n"; + + const GLuint program = MakeComputeProgram(source); + if (program == 0) return; + + // Each unit gets its own value, so the sum says how many units contributed. + GLuint expected = 0; + for (std::size_t i = 0; i < kinds.size(); ++i) { + const GLuint texture = MakeTexture(kinds[i], true); + if (texture == 0) return; + expected += kFilledValue; + glBindImageTexture(static_cast(i), texture, 0, GL_TRUE, 0, GL_READ_ONLY, GL_R32UI); + ASSERT_EQ(FirstGLError(), 0u) << kinds[i].name << ": glBindImageTexture errored"; + } + const GLuint ssbo = MakeResultBuffer(); + + glUseProgram(program); + for (std::size_t i = 0; i < kinds.size(); ++i) { + glUniform1i(static_cast(i), static_cast(i)); + } + ASSERT_EQ(FirstGLError(), 0u) << "assigning the image units errored"; + + glDispatchCompute(1, 1, 1); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + EXPECT_EQ(FirstGLError(), 0u) << "the dispatch leaked a GL error"; + + EXPECT_EQ(ReadResult(ssbo), expected) + << "the sum over " << kinds.size() + << " image target kinds is wrong; each kind contributes " << kFilledValue + << ", so the shortfall is a whole number of kinds that read zero"; + glUseProgram(0); + } + +} // namespace MGITest diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index 27b702b2..99a220a2 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -3481,3 +3482,162 @@ void main() { fragColor = vec4(texelFetch(Data, 3)); } << essl320; } + +namespace { + // OpTypeImage words: result id (+1), sampled type (+2), Dim (+3), Depth (+4), Arrayed (+5), + // MS (+6), Sampled (+7). Dim::Dim1D == 0, and Sampled == 2 is a storage image. + SizeT Count1DArrayStorageImageTypes(const Vector& spirv) { + constexpr unsigned kOpTypeImage = 25, kDim1D = 0; + SizeT count = 0; + for (SizeT i = 5; i < spirv.size();) { + const unsigned wordCount = spirv[i] >> 16; + const unsigned opcode = spirv[i] & 0xFFFFu; + if (wordCount == 0 || i + wordCount > spirv.size()) break; + if (opcode == kOpTypeImage && wordCount >= 8 && spirv[i + 3] == kDim1D && spirv[i + 5] == 1u && + spirv[i + 7] == 2u) { + ++count; + } + i += wordCount; + } + return count; + } + + const char* k1DArrayImageCompute = R"(#version 440 core +layout (local_size_x = 1) in; +layout (location = 0, r32ui) readonly uniform uimage1DArray i0; +layout (std430, binding = 0) buffer SSB { uint sum; } ssb; +void main() { ssb.sum = imageLoad(i0, ivec2(2, 3)).r; } +)"; +} // namespace + +// The negative control, and the whole reason the pass exists: SPIRV-Cross's ES emulation of 1D +// images does not ask whether the type is arrayed, so it wraps an already-two-component +// coordinate in a two-component constructor. Pinning the upstream behaviour here means that if a +// future SPIRV-Cross bump fixes it, this test fails and says so, rather than the pass quietly +// becoming dead weight. +TEST_F(ProgramUtilTest, SpirvCrossEmitsAMalformedCoordinateFor1DArrayImages) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(k1DArrayImageCompute, GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_EQ(Count1DArrayStorageImageTypes(spirv), 1u) + << "glslang no longer emits a Dim1D/Arrayed/Sampled=2 image for uimage1DArray"; + + const String essl = DecompileToEssl(spirv); + ASSERT_FALSE(essl.empty()); + EXPECT_NE(essl.find("ivec2(ivec2("), String::npos) + << "SPIRV-Cross is expected to emit ivec2(ivec2(...), 0) here - three components in a " + "two-component constructor, which every ES driver rejects. If this no longer happens, " + "Lower1DArrayImagesForEssl may no longer be needed:\n" + << essl; +} + +// The fix: the type becomes a 2D array and the coordinate becomes three components, so +// SPIRV-Cross's 1D path never fires and the emitted ESSL is something a driver accepts. +TEST_F(ProgramUtilTest, Lower1DArrayImagesRewritesTheTypeAndWidensTheCoordinate) { + using namespace MG_Util::ShaderTranspiler; + + const Vector raw = BuildSpirvForStage(k1DArrayImageCompute, GL_COMPUTE_SHADER); + ASSERT_FALSE(raw.empty()); + + // Through the shared chain first, exactly as the DirectGLES transpile path does: the pass + // runs on sanitized bytes, and the explicit uniform LOCATION this fixture carries (the + // conformance case's own spelling) is illegal on UniformConstant storage until + // StripUniformLocationsPass has removed it. Validating raw glslang output would latch that + // pre-existing property against this pass. + Vector spirv; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv)); + ASSERT_EQ(Count1DArrayStorageImageTypes(spirv), 1u) + << "the shared chain must leave the 1D-array image for this pass to handle"; + + SpirvValidationScope validationOn(true); + const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount(); + + Vector lowered; + ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered)); + ASSERT_FALSE(lowered.empty()); + + EXPECT_EQ(Count1DArrayStorageImageTypes(lowered), 0u) + << "no 1D-array storage image type may survive the pass:\n" + << DisassembleSpirv(lowered); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the lowered module must stay validator-clean"; + + const String essl = DecompileToEssl(lowered); + ASSERT_FALSE(essl.empty()); + EXPECT_NE(essl.find("uimage2DArray"), String::npos) + << "the image must be declared as the 2D array the texture is stored as:\n" << essl; + EXPECT_EQ(essl.find("ivec2(ivec2("), String::npos) + << "the malformed constructor must be gone:\n" << essl; + EXPECT_NE(essl.find("ivec3("), String::npos) + << "the coordinate must have been widened to three components:\n" << essl; +} + +// Scope, half one: a NON-arrayed 1D storage image is emitted correctly by the very same +// SPIRV-Cross code, so the pass must not touch it - replacing working emission with our own buys +// nothing and risks everything. +TEST_F(ProgramUtilTest, Lower1DArrayImagesLeavesNonArrayed1DImagesToSpirvCross) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 440 core +layout (local_size_x = 1) in; +layout (location = 0, r32ui) readonly uniform uimage1D i0; +layout (std430, binding = 0) buffer SSB { uint sum; } ssb; +void main() { ssb.sum = imageLoad(i0, 2).r; } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + + Vector lowered; + ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered)); + EXPECT_EQ(lowered, spirv) << "a non-arrayed 1D storage image must pass through byte for byte"; + + const String essl = DecompileToEssl(lowered); + ASSERT_FALSE(essl.empty()); + EXPECT_NE(essl.find("uimage2D "), String::npos) + << "SPIRV-Cross's own 1D-as-2D emulation must still be what handles this:\n" << essl; +} + +// Scope, half two: a 1D-array SAMPLER reaches SPIRV-Cross's sampler path, which does check +// `arrayed` and does move the layer into the third component. The pass is storage-image only. +TEST_F(ProgramUtilTest, Lower1DArrayImagesLeavesSampledImagesAlone) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 440 core +uniform sampler1DArray uTex; +in vec2 vUv; +out vec4 fragColor; +void main() { fragColor = texture(uTex, vUv); } +)", + GL_FRAGMENT_SHADER); + ASSERT_FALSE(spirv.empty()); + + Vector lowered; + ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered)); + EXPECT_EQ(lowered, spirv) << "a sampled 1D-array image must pass through byte for byte"; +} + +// The declined shape. After the rewrite the image is a 2D array, so a size query on it yields +// three components where the shader consumes two, and there is no correct two-component answer to +// substitute - the ES texture genuinely has a height the GL one does not. The module is handed +// back untouched rather than half-translated. +TEST_F(ProgramUtilTest, Lower1DArrayImagesDeclinesAModuleThatQueriesTheImageSize) { + using namespace MG_Util::ShaderTranspiler; + + const Vector spirv = BuildSpirvForStage(R"(#version 440 core +layout (local_size_x = 1) in; +layout (location = 0, r32ui) readonly uniform uimage1DArray i0; +layout (std430, binding = 0) buffer SSB { uint sum; } ssb; +void main() { ssb.sum = uint(imageSize(i0).x) + imageLoad(i0, ivec2(0, 0)).r; } +)", + GL_COMPUTE_SHADER); + ASSERT_FALSE(spirv.empty()); + ASSERT_TRUE(Lower1DArrayImagesPass::BinaryQueriesA1DArrayStorageImageSize(spirv)) + << "the fixture must contain the shape the pass declines"; + + Vector lowered; + ASSERT_TRUE(ShaderCompiler::Lower1DArrayImagesForEssl(spirv, lowered)); + EXPECT_EQ(lowered, spirv) << "a declined module must be handed back untouched, not partly rewritten"; + EXPECT_EQ(Count1DArrayStorageImageTypes(lowered), 1u) + << "declining means the 1D-array type is still there for the driver to reject"; +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index b8a56feb..632845d9 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -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& inputBinary, + Vector& 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& inputBinary, Vector& outputBinary) { using namespace spvtools; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 5bce2494..47ce045e 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -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& inputBinary, Vector& 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& inputBinary, + Vector& outputBinary); static bool RebaseInstanceIndexForVulkan(const Vector& inputBinary, Vector& outputBinary); // Builds the non-indexed-draw variant of a vertex shader: every gl_BaseVertex diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp new file mode 100644 index 00000000..f4af03a6 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp @@ -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 +#include + +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(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(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& 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; + } + 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(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(capability.GetSingleWordInOperand(0)); + if (value == spv::Capability::Image1D) { + capability.SetInOperand(0, {static_cast(spv::Capability::Shader)}); + } + } + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken Lower1DArrayImagesPass::CreateLower1DArrayImagesPass() { + return spvtools::Optimizer::PassToken(spvtools::MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.h new file mode 100644 index 00000000..0db29f9a --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.h @@ -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 + +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& binary); + + static spvtools::Optimizer::PassToken CreateLower1DArrayImagesPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL