// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.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 "VkTextureManager.h"

#include "ProgramFactory.h"

#include "MG_State/GLState/Core.h"
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"

#include <Config.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <vulkan/utility/vk_format_utils.h>

namespace MobileGL::MG_Backend::DirectVulkan {
    // Compute shaders may legally sample framebuffer-attached textures (the GL feedback-loop rule
    // only covers rendering commands; e.g. Flywheel's Hi-Z depth pyramid downsample samples the
    // depth attachment of the bound draw framebuffer), so sampled-read barriers must cover the
    // compute stage in addition to the graphics stages. Set at Initialize from the renderer's
    // device-feature-derived mask: geometry/tessellation stage bits are invalid in a barrier when
    // their feature is off (VUID-vkCmdPipelineBarrier-srcStageMask-04090/-04091), and ALL_GRAPHICS
    // would also serialize against non-shader stages. The default only matters before a device
    // exists, when nothing records barriers.
    static VkPipelineStageFlags s_sampledReadStages =
        VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT |
        VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;

    static Uint32 ComputeFullMipLevelCount(const IntVec3& baseTexelSize) {
        Int maxDimension = std::max<Int>(baseTexelSize.x(),
                                         std::max<Int>(baseTexelSize.y(), std::max<Int>(baseTexelSize.z(), 1)));
        Uint32 mipLevelCount = 1;
        while (maxDimension > 1) {
            maxDimension = std::max<Int>(maxDimension / 2, 1);
            ++mipLevelCount;
        }
        return mipLevelCount;
    }

    struct TextureShapeInfo {
        VkImageType imageType = VK_IMAGE_TYPE_2D;
        VkImageViewType viewType = VK_IMAGE_VIEW_TYPE_2D;
        VkImageCreateFlags imageFlags = 0;
        Uint32 depth = 1;
        Uint32 arrayLayers = 1;
    };

    static Bool IsR11G11B10FFallbackEnabled() {
        return MG_Config::Features.MagmaR11G11B10FFallback;
    }

    static Bool IsMultisampleTextureUploadTarget(TextureUploadTarget target) {
        return target == TextureUploadTarget::Texture2DMultisample ||
               target == TextureUploadTarget::ProxyTexture2DMultisample ||
               target == TextureUploadTarget::Texture2DMultisampleArray ||
               target == TextureUploadTarget::ProxyTexture2DMultisampleArray;
    }

    static Bool IsMutableStorageImageFormat(VkFormat format) {
        if (!vkuFormatIsColor(format) || vkuFormatIsCompressed(format)) {
            return false;
        }

        // These are the uncompressed color compatibility classes covered by the core GLSL/SPIR-V
        // storage-image formats. OpenGL mutable texture storage uses image-format compatibility by
        // size, so a shader may legally reinterpret (for example) RGBA16_UNORM storage as rgba16f. Vulkan
        // requires the image to be mutable and the view formats to share this exact compatibility
        // class for the equivalent operation.
        switch (vkuFormatCompatibilityClass(format)) {
        case VKU_FORMAT_COMPATIBILITY_CLASS_8BIT:
        case VKU_FORMAT_COMPATIBILITY_CLASS_16BIT:
        case VKU_FORMAT_COMPATIBILITY_CLASS_32BIT:
        case VKU_FORMAT_COMPATIBILITY_CLASS_64BIT:
        case VKU_FORMAT_COMPATIBILITY_CLASS_128BIT:
            return true;
        default:
            return false;
        }
    }

    static Bool HasMatchingColorComponentLayout(VkFormat lhs, VkFormat rhs) {
        const VKU_FORMAT_INFO lhsInfo = vkuGetFormatInfo(lhs);
        const VKU_FORMAT_INFO rhsInfo = vkuGetFormatInfo(rhs);
        if (lhsInfo.component_count == 0 || lhsInfo.component_count != rhsInfo.component_count ||
            lhsInfo.texel_block_size != rhsInfo.texel_block_size ||
            lhsInfo.texels_per_block != 1 || rhsInfo.texels_per_block != 1) {
            return false;
        }
        for (Uint32 component = 0; component < lhsInfo.component_count; ++component) {
            if (lhsInfo.components[component].type != rhsInfo.components[component].type ||
                lhsInfo.components[component].size != rhsInfo.components[component].size) {
                return false;
            }
        }
        return true;
    }

    static Bool FormatMatchesSamplerNumericDomain(VkFormat format, SamplerNumericDomain numericDomain) {
        switch (numericDomain) {
        case SamplerNumericDomain::Float:
            return vkuFormatIsSampledFloat(format);
        case SamplerNumericDomain::SignedInteger:
            return vkuFormatIsSINT(format);
        case SamplerNumericDomain::UnsignedInteger:
            return vkuFormatIsUINT(format);
        case SamplerNumericDomain::Unknown:
            return true;
        }
        return false;
    }

    static Bool TryResolveSampleCountFlagBits(Int requestedSamples, VkSampleCountFlagBits& outSampleCount) {
        // GL promises "at least the requested samples", so a non-power-of-two
        // request (legal in GL, e.g. 3) rounds up to the next Vulkan bit.
        if (requestedSamples <= 1) {
            outSampleCount = VK_SAMPLE_COUNT_1_BIT;
            return true;
        }
        if (requestedSamples > 64) {
            return false;
        }
        Uint32 bit = 1;
        while (bit < static_cast<Uint32>(requestedSamples)) {
            bit <<= 1;
        }
        outSampleCount = static_cast<VkSampleCountFlagBits>(bit);
        return true;
    }

    static Bool IsCubeMapFaceUploadTarget(TextureUploadTarget target) {
        return target >= TextureUploadTarget::CubeMapPositiveX &&
               target <= TextureUploadTarget::CubeMapNegativeZ;
    }

    static Uint32 ResolveUploadArrayLayer(TextureUploadTarget target) {
        if (!IsCubeMapFaceUploadTarget(target)) {
            return 0;
        }
        return static_cast<Uint32>(target) - static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX);
    }

    static Bool IsValidSampledImageLayout(VkImageLayout layout) {
        switch (layout) {
        case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_GENERAL:
        case VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_STENCIL_ATTACHMENT_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_STENCIL_READ_ONLY_OPTIMAL:
            return true;
        default:
            return false;
        }
    }

    static VkImageLayout ResolveSampledReadOnlyLayout(VkImageAspectFlags aspectMask) {
        return (aspectMask & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT)) != 0
            ? VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
            : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    }

    static void GetImageTransitionSourceState(VkImageLayout oldLayout,
                                              VkPipelineStageFlags& outSrcStageMask,
                                              VkAccessFlags& outSrcAccessMask) {
        switch (oldLayout) {
        case VK_IMAGE_LAYOUT_UNDEFINED:
            outSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
            outSrcAccessMask = 0;
            return;
        case VK_IMAGE_LAYOUT_GENERAL:
            outSrcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
            outSrcAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
            outSrcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
            outSrcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL:
            outSrcStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
            outSrcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
                               VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_STENCIL_ATTACHMENT_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_STENCIL_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:
            outSrcStageMask = s_sampledReadStages;
            outSrcAccessMask = VK_ACCESS_SHADER_READ_BIT;
            return;
        case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL:
            outSrcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
            outSrcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
            return;
        case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:
            outSrcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
            outSrcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
            return;
        default:
            MOBILEGL_ASSERT(false, "GetImageTransitionSourceState: unsupported layout=%d", static_cast<Int>(oldLayout));
            outSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
            outSrcAccessMask = 0;
            return;
        }
    }

    VkTextureManager::TextureIdentity VkTextureManager::MakeTextureIdentity(
        MG_State::GLState::ITextureObject* texture) {
        // A GL texture view (ARB_texture_view) is identified by the texture whose STORAGE it
        // views, not by itself. Everything this identity keys - the TextureResource, the tracked
        // image layout, the alive-object weak reference, the storage-usage marks, the per-draw
        // sync memos - is a property of the IMAGE, and a view shares that image exactly. Doing
        // the resolution here rather than at each call site is what makes it impossible to miss
        // one: a layout update posted against a view's own identity would have found no resource
        // at all, which is precisely how an attached view came back blank.
        //
        // One hop suffices and cannot recurse: glTextureView composes a view-of-a-view onto the
        // root at creation, so a storage owner is never itself a view.
        if (texture != nullptr) {
            const auto& storageOwner = texture->GetViewStorageOwner();
            if (storageOwner) {
                texture = storageOwner.get();
            }
        }
        return TextureIdentity{
            .texture = texture,
            .lifetimeId = texture ? texture->GetLifetimeId() : 0,
        };
    }

    static void GetImageTransitionDestinationState(VkImageLayout newLayout,
                                                   VkPipelineStageFlags& outDstStageMask,
                                                   VkAccessFlags& outDstAccessMask) {
        switch (newLayout) {
        case VK_IMAGE_LAYOUT_GENERAL:
            outDstStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
            outDstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
            outDstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
            outDstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL:
            outDstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
            outDstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
                               VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
            return;
        case VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_STENCIL_ATTACHMENT_OPTIMAL:
        case VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_STENCIL_READ_ONLY_OPTIMAL:
        case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:
            outDstStageMask = s_sampledReadStages;
            outDstAccessMask = VK_ACCESS_SHADER_READ_BIT;
            return;
        case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL:
            outDstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
            outDstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
            return;
        case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:
            outDstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
            outDstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
            return;
        default:
            MOBILEGL_ASSERT(false, "GetImageTransitionDestinationState: unsupported layout=%d", static_cast<Int>(newLayout));
            outDstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
            outDstAccessMask = 0;
            return;
        }
    }

    static Bool PreserveTextureContentsOnRecreate(VkDevice device,
                                                  VkCommandPool commandPool,
                                                  VkQueue graphicsQueue,
                                                  const VkTextureManager::TextureResource& oldResource,
                                                  VkTextureManager::TextureResource& newResource) {
        MOBILEGL_ASSERT(device != VK_NULL_HANDLE, "PreserveTextureContentsOnRecreate: device is null");
        MOBILEGL_ASSERT(commandPool != VK_NULL_HANDLE, "PreserveTextureContentsOnRecreate: commandPool is null");
        MOBILEGL_ASSERT(graphicsQueue != VK_NULL_HANDLE, "PreserveTextureContentsOnRecreate: graphicsQueue is null");
        MOBILEGL_ASSERT(oldResource.image != VK_NULL_HANDLE, "PreserveTextureContentsOnRecreate: old image is null");
        MOBILEGL_ASSERT(newResource.image != VK_NULL_HANDLE, "PreserveTextureContentsOnRecreate: new image is null");

        const Uint32 preservedMipLevels = std::min(oldResource.mipLevels, newResource.mipLevels);
        if (preservedMipLevels == 0 || oldResource.layout == VK_IMAGE_LAYOUT_UNDEFINED) {
            return true;
        }

        VkCommandBufferAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
        allocInfo.commandPool = commandPool;
        allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
        allocInfo.commandBufferCount = 1;

        VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
        VK_VERIFY(vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer),
                  "vkAllocateCommandBuffers(texture preserve)");

        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
        beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
        VK_VERIFY(vkBeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer(texture preserve)");

        Bool ok = VkTextureManager::TransitionImageLayout(
            commandBuffer, newResource.image, newResource.layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
            VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
            0, VK_ACCESS_TRANSFER_WRITE_BIT, newResource.aspect, 0, newResource.mipLevels);
        MOBILEGL_ASSERT(ok, "PreserveTextureContentsOnRecreate: failed to prepare destination image");

        VkImageLayout srcTrackedLayout = oldResource.layout;
        VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        VkAccessFlags srcAccessMask = 0;
        GetImageTransitionSourceState(srcTrackedLayout, srcStageMask, srcAccessMask);
        ok = VkTextureManager::TransitionImageLayout(
            commandBuffer, oldResource.image, srcTrackedLayout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
            srcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT,
            srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, oldResource.aspect, 0, preservedMipLevels);
        MOBILEGL_ASSERT(ok, "PreserveTextureContentsOnRecreate: failed to prepare source image");

        Vector<VkImageCopy> copyRegions;
        copyRegions.reserve(preservedMipLevels);
        for (Uint32 level = 0; level < preservedMipLevels; ++level) {
            VkImageCopy copy{};
            copy.srcSubresource.aspectMask = oldResource.aspect;
            copy.srcSubresource.mipLevel = level;
            copy.srcSubresource.baseArrayLayer = 0;
            copy.srcSubresource.layerCount = oldResource.arrayLayers;
            copy.dstSubresource.aspectMask = newResource.aspect;
            copy.dstSubresource.mipLevel = level;
            copy.dstSubresource.baseArrayLayer = 0;
            copy.dstSubresource.layerCount = newResource.arrayLayers;
            copy.extent.width = std::max(oldResource.extent.width >> level, 1u);
            copy.extent.height = std::max(oldResource.extent.height >> level, 1u);
            copy.extent.depth = std::max(oldResource.depth >> level, 1u);
            copyRegions.push_back(copy);
        }

        vkCmdCopyImage(commandBuffer,
                       oldResource.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                       newResource.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                       static_cast<Uint32>(copyRegions.size()), copyRegions.data());

        VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        VkAccessFlags dstAccessMask = 0;
        GetImageTransitionDestinationState(oldResource.layout, dstStageMask, dstAccessMask);
        ok = VkTextureManager::TransitionImageLayout(
            commandBuffer, newResource.image, newResource.layout, oldResource.layout,
            VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask,
            VK_ACCESS_TRANSFER_WRITE_BIT, dstAccessMask, newResource.aspect, 0, newResource.mipLevels);
        MOBILEGL_ASSERT(ok, "PreserveTextureContentsOnRecreate: failed to restore destination layout");

        VK_VERIFY(vkEndCommandBuffer(commandBuffer), "vkEndCommandBuffer(texture preserve)");

        VkSubmitInfo submitInfo{};
        submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
        submitInfo.commandBufferCount = 1;
        submitInfo.pCommandBuffers = &commandBuffer;

        VkFenceCreateInfo fenceInfo{};
        fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
        VkFence fence = VK_NULL_HANDLE;
        VK_VERIFY(vkCreateFence(device, &fenceInfo, nullptr, &fence), "vkCreateFence(texture preserve)");
        VK_VERIFY(vkQueueSubmit(graphicsQueue, 1, &submitInfo, fence), "vkQueueSubmit(texture preserve)");
        VK_VERIFY(vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX), "vkWaitForFences(texture preserve)");

        vkDestroyFence(device, fence, nullptr);
        vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
        return true;
    }

    TextureFormatInfo ResolveTextureFormatInfo(TextureInternalFormat format) {
        switch (format) {
        case TextureInternalFormat::RGB:
        case TextureInternalFormat::RGB8:
        // Legacy low-bit RGB formats share the UNorm8 canonical shadow layout (see
        // TextureFormatProcessor), so they upload exactly like RGB8 with an alpha expand.
        case TextureInternalFormat::R3G3B2:
        case TextureInternalFormat::RGB4:
        case TextureInternalFormat::RGB5:
            return {VK_FORMAT_R8G8B8A8_UNORM, true, 1, {0xFF, 0x00, 0x00, 0x00}};
        // Low-bit RGBA formats: UNorm8x4 canonical shadow, no expansion needed.
        case TextureInternalFormat::RGBA2:
        case TextureInternalFormat::RGBA4:
        case TextureInternalFormat::RGB5A1:
            return {VK_FORMAT_R8G8B8A8_UNORM, false, 0, {0, 0, 0, 0}};
        // 10/12-bit RGB(A): UNorm16 canonical shadow.
        case TextureInternalFormat::RGB10:
        case TextureInternalFormat::RGB12:
            return {VK_FORMAT_R16G16B16A16_UNORM, true, 2, {0xFF, 0xFF, 0x00, 0x00}};
        case TextureInternalFormat::RGBA12:
            return {VK_FORMAT_R16G16B16A16_UNORM, false, 0, {0, 0, 0, 0}};
        case TextureInternalFormat::SRGB8:
            return {VK_FORMAT_R8G8B8A8_SRGB, true, 1, {0xFF, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB8Snorm:
            return {VK_FORMAT_R8G8B8A8_SNORM, true, 1, {0x7F, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB16:
            return {VK_FORMAT_R16G16B16A16_UNORM, true, 2, {0xFF, 0xFF, 0x00, 0x00}};
        case TextureInternalFormat::RGB16Snorm:
            return {VK_FORMAT_R16G16B16A16_SNORM, true, 2, {0xFF, 0x7F, 0x00, 0x00}};
        case TextureInternalFormat::RGB16F:
            return {VK_FORMAT_R16G16B16A16_SFLOAT, true, 2, {0x00, 0x3C, 0x00, 0x00}};
        case TextureInternalFormat::R11FG11FB10F:
            if (IsR11G11B10FFallbackEnabled()) {
                return {VK_FORMAT_R16G16B16A16_SFLOAT, true, 2, {0x00, 0x3C, 0x00, 0x00}};
            }
            return {MG_Util::ConvertTextureInternalFormatToVkEnum(format), false, 0, {0, 0, 0, 0}};
        case TextureInternalFormat::RGB32F:
            return {VK_FORMAT_R32G32B32A32_SFLOAT, true, 4, {0x00, 0x00, 0x80, 0x3F}};
        case TextureInternalFormat::RGB8I:
            return {VK_FORMAT_R8G8B8A8_SINT, true, 1, {0x01, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB8UI:
            return {VK_FORMAT_R8G8B8A8_UINT, true, 1, {0x01, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB16I:
            return {VK_FORMAT_R16G16B16A16_SINT, true, 2, {0x01, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB16UI:
            return {VK_FORMAT_R16G16B16A16_UINT, true, 2, {0x01, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB32I:
            return {VK_FORMAT_R32G32B32A32_SINT, true, 4, {0x01, 0x00, 0x00, 0x00}};
        case TextureInternalFormat::RGB32UI:
            return {VK_FORMAT_R32G32B32A32_UINT, true, 4, {0x01, 0x00, 0x00, 0x00}};
        default:
            return {MG_Util::ConvertTextureInternalFormatToVkEnum(format), false, 0, {0, 0, 0, 0}};
        }
    }

    static Bool ExpandRgbSourceToRgba(const void* source, SizeT sourceByteSize, const IntVec3& texelSize,
                                      const TextureFormatInfo& formatInfo, Vector<Uint8>& outExpandedData) {
        MOBILEGL_ASSERT(source != nullptr, "ExpandRgbSourceToRgba: source is null");
        MOBILEGL_ASSERT(formatInfo.expandRgbToRgba, "ExpandRgbSourceToRgba: format does not require RGB expansion");
        MOBILEGL_ASSERT(formatInfo.componentByteCount > 0,
                        "ExpandRgbSourceToRgba: invalid component size for expanded RGB format");

        const SizeT depth = static_cast<SizeT>(std::max(texelSize.z(), 1));
        const SizeT pixelCount = static_cast<SizeT>(texelSize.x()) * static_cast<SizeT>(texelSize.y()) * depth;
        MOBILEGL_ASSERT(pixelCount > 0, "ExpandRgbSourceToRgba: invalid texel size (%d, %d, %d)",
                        texelSize.x(), texelSize.y(), texelSize.z());
        MOBILEGL_ASSERT(sourceByteSize == pixelCount * formatInfo.componentByteCount * 3,
                        "ExpandRgbSourceToRgba: unexpected source byte size=%zu for pixelCount=%zu componentBytes=%u",
                        sourceByteSize, pixelCount, formatInfo.componentByteCount);

        outExpandedData.resize(pixelCount * formatInfo.componentByteCount * 4);
        const auto* src = static_cast<const Uint8*>(source);
        auto* dst = outExpandedData.data();
        const SizeT srcPixelSize = static_cast<SizeT>(formatInfo.componentByteCount) * 3;
        const SizeT dstPixelSize = static_cast<SizeT>(formatInfo.componentByteCount) * 4;
        for (SizeT pixel = 0; pixel < pixelCount; ++pixel) {
            const SizeT srcOffset = pixel * srcPixelSize;
            const SizeT dstOffset = pixel * dstPixelSize;
            std::memcpy(dst + dstOffset, src + srcOffset, srcPixelSize);
            std::memcpy(dst + dstOffset + srcPixelSize, formatInfo.alphaBytes.data(), formatInfo.componentByteCount);
        }
        return true;
    }

    static VkComponentSwizzle ToVkComponentSwizzle(TextureSwizzleParam swizzle) {
        switch (swizzle) {
        case TextureSwizzleParam::Red:
            return VK_COMPONENT_SWIZZLE_R;
        case TextureSwizzleParam::Green:
            return VK_COMPONENT_SWIZZLE_G;
        case TextureSwizzleParam::Blue:
            return VK_COMPONENT_SWIZZLE_B;
        case TextureSwizzleParam::Alpha:
            return VK_COMPONENT_SWIZZLE_A;
        case TextureSwizzleParam::Zero:
            return VK_COMPONENT_SWIZZLE_ZERO;
        case TextureSwizzleParam::One:
            return VK_COMPONENT_SWIZZLE_ONE;
        default:
            MOBILEGL_ASSERT(false, "ToVkComponentSwizzle: unsupported swizzle=%d", static_cast<Int>(swizzle));
            return VK_COMPONENT_SWIZZLE_IDENTITY;
        }
    }

    static VkComponentSwizzle ToVkSampledComponentSwizzle(TextureSwizzleParam swizzle, Bool alphaIsImplicitOne) {
        if (alphaIsImplicitOne && swizzle == TextureSwizzleParam::Alpha) {
            return VK_COMPONENT_SWIZZLE_ONE;
        }
        return ToVkComponentSwizzle(swizzle);
    }

    static VkComponentMapping ResolveSampledViewComponents(const MG_State::GLState::ITextureObject& texture,
                                                           const TextureFormatInfo& formatInfo) {
        const auto& swizzles = texture.GetAllSwizzleParams();
        const Bool alphaIsImplicitOne = formatInfo.expandRgbToRgba;
        VkComponentMapping components{
            ToVkSampledComponentSwizzle(swizzles.x(), alphaIsImplicitOne),
            ToVkSampledComponentSwizzle(swizzles.y(), alphaIsImplicitOne),
            ToVkSampledComponentSwizzle(swizzles.z(), alphaIsImplicitOne),
            ToVkSampledComponentSwizzle(swizzles.w(), alphaIsImplicitOne),
        };
        return components;
    }

    static Bool TryResolveTextureShapeInfo(const MG_State::GLState::ITextureObject& texture,
                                           TextureUploadTarget uploadTarget, const IntVec3& texelSize,
                                           TextureShapeInfo& outShape) {
        switch (uploadTarget) {
        case TextureUploadTarget::Texture1D:
        case TextureUploadTarget::ProxyTexture1D:
            outShape = {};
            outShape.imageType = VK_IMAGE_TYPE_1D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_1D;
            return true;
        case TextureUploadTarget::Texture1DArray:
        case TextureUploadTarget::ProxyTexture1DArray:
            MOBILEGL_ASSERT(texelSize.z() > 0,
                            "TryResolveTextureShapeInfo: invalid 1D array depth=%d for textureId=%d",
                            texelSize.z(), texture.GetExternalIndex());
            outShape.imageType = VK_IMAGE_TYPE_1D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_1D_ARRAY;
            outShape.depth = 1;
            outShape.arrayLayers = static_cast<Uint32>(texelSize.z());
            return true;
        case TextureUploadTarget::Texture2D:
        case TextureUploadTarget::ProxyTexture2D:
        case TextureUploadTarget::TextureRectangle:
        case TextureUploadTarget::ProxyTextureRectangle:
            outShape = {};
            return true;
        case TextureUploadTarget::Texture2DMultisample:
        case TextureUploadTarget::ProxyTexture2DMultisample:
            outShape = {};
            return true;
        case TextureUploadTarget::Texture2DArray:
        case TextureUploadTarget::ProxyTexture2DArray:
            MOBILEGL_ASSERT(texelSize.z() > 0,
                            "TryResolveTextureShapeInfo: invalid 2D array depth=%d for textureId=%d",
                            texelSize.z(), texture.GetExternalIndex());
            outShape.imageType = VK_IMAGE_TYPE_2D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY;
            outShape.depth = 1;
            outShape.arrayLayers = static_cast<Uint32>(texelSize.z());
            return true;
        case TextureUploadTarget::Texture2DMultisampleArray:
        case TextureUploadTarget::ProxyTexture2DMultisampleArray:
            MOBILEGL_ASSERT(texelSize.z() > 0,
                            "TryResolveTextureShapeInfo: invalid 2D multisample array depth=%d for textureId=%d",
                            texelSize.z(), texture.GetExternalIndex());
            outShape.imageType = VK_IMAGE_TYPE_2D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY;
            outShape.depth = 1;
            outShape.arrayLayers = static_cast<Uint32>(texelSize.z());
            return true;
        case TextureUploadTarget::Texture3D:
        case TextureUploadTarget::ProxyTexture3D:
            MOBILEGL_ASSERT(texelSize.z() > 0,
                            "TryResolveTextureShapeInfo: invalid 3D texture depth=%d for textureId=%d",
                            texelSize.z(), texture.GetExternalIndex());
            outShape.imageType = VK_IMAGE_TYPE_3D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_3D;
            outShape.depth = static_cast<Uint32>(texelSize.z());
            return true;
        case TextureUploadTarget::CubeMapPositiveX:
        case TextureUploadTarget::CubeMapNegativeX:
        case TextureUploadTarget::CubeMapPositiveY:
        case TextureUploadTarget::CubeMapNegativeY:
        case TextureUploadTarget::CubeMapPositiveZ:
        case TextureUploadTarget::CubeMapNegativeZ:
        case TextureUploadTarget::ProxyCubeMap:
            MOBILEGL_ASSERT(texture.GetTarget() == TextureTarget::TextureCubeMap,
                            "TryResolveTextureShapeInfo: cube upload target on non-cube textureId=%d target=%s",
                            texture.GetExternalIndex(),
                            MG_Util::ConvertTextureTargetToString(texture.GetTarget()).c_str());
            MOBILEGL_ASSERT(texelSize.x() == texelSize.y(),
                            "TryResolveTextureShapeInfo: cube map textureId=%d is not square (%d x %d)",
                            texture.GetExternalIndex(), texelSize.x(), texelSize.y());
            outShape.imageType = VK_IMAGE_TYPE_2D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_CUBE;
            outShape.imageFlags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
            outShape.depth = 1;
            outShape.arrayLayers = 6;
            return true;
        case TextureUploadTarget::CubeMapArray:
        case TextureUploadTarget::ProxyCubeMapArray:
            // GL_TEXTURE_CUBE_MAP_ARRAY is an array texture whose layers happen to be cube faces:
            // one 2D image with arrayLayers = 6 * cubeCount, CUBE_COMPATIBLE so the whole thing can
            // be sampled as a samplerCubeArray. glTexStorage3D hands the 6*n through as the GL depth
            // and the upload path's depthSelectsArrayLayer already lists VK_IMAGE_VIEW_TYPE_CUBE_ARRAY,
            // so the copies address layers correctly.
            //
            // A depth that is not a whole number of cubes, or a non-square level, has no Vulkan shape
            // - declined the way every other unrepresentable target is. This function's Bool return
            // exists for exactly that; asserting here would abort the process on ordinary application
            // input, GL_PROXY_TEXTURE_CUBE_MAP_ARRAY above all.
            if (texelSize.z() <= 0 || (texelSize.z() % 6) != 0 || texelSize.x() != texelSize.y()) {
                return false;
            }
            outShape.imageType = VK_IMAGE_TYPE_2D;
            outShape.viewType = VK_IMAGE_VIEW_TYPE_CUBE_ARRAY;
            outShape.imageFlags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
            outShape.depth = 1;
            outShape.arrayLayers = static_cast<Uint32>(texelSize.z());
            return true;
        default:
            return false;
        }
    }

    Bool VkTextureManager::Initialize(const InitInfo& initInfo) {
        Shutdown();

        m_device = initInfo.device;
        m_physicalDevice = initInfo.physicalDevice;
        m_allocator = initInfo.allocator;
        m_commandPool = initInfo.commandPool;
        m_graphicsQueue = initInfo.graphicsQueue;
        m_imageFormatListSupported = initInfo.imageFormatListSupported;
        s_sampledReadStages = initInfo.sampledReadStageMask;
        m_currentFrameIndex = 0;
        m_deferredReleases.clear();
        m_deferredReleases.resize(initInfo.frameCount);
        m_deferredViewReleases.clear();
        m_deferredViewReleases.resize(initInfo.frameCount);

        MOBILEGL_ASSERT(m_device != VK_NULL_HANDLE && m_physicalDevice != VK_NULL_HANDLE && m_allocator != nullptr &&
                            m_commandPool != VK_NULL_HANDLE && m_graphicsQueue != VK_NULL_HANDLE,
                        "VkTextureManager::Initialize failed: invalid initialization info");
        MOBILEGL_ASSERT(initInfo.frameCount > 0,
                        "VkTextureManager::Initialize failed: frameCount must be > 0");

        TextureResource::s_device = m_device;
        TextureResource::s_allocator = m_allocator;

        // Own pool for the recycled upload-batch command buffers. Parking a
        // dozen reset-but-alive command buffers in the renderer's shared pool
        // interleaves their retained chunks with the frame command buffers
        // allocated/freed there every frame; isolating them keeps both pools'
        // internal allocators dense.
        VkCommandPoolCreateInfo uploadPoolInfo{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
        uploadPoolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT |
                               VK_COMMAND_POOL_CREATE_TRANSIENT_BIT;
        uploadPoolInfo.queueFamilyIndex = initInfo.graphicsQueueFamilyIndex;
        VK_VERIFY(vkCreateCommandPool(m_device, &uploadPoolInfo, nullptr, &m_uploadCommandPool),
                  "vkCreateCommandPool(texture upload batch)");

        return true;
    }

    void VkTextureManager::Shutdown() {
        if (m_device != VK_NULL_HANDLE) {
            // A still-open (never-submitted) batch is discarded, not submitted:
            // the renderer has already drained the device and the data has no
            // observer. Submitted batches are waited and recycled, then the
            // pools they recycled into are destroyed.
            DiscardPendingUploadBatch();
            ReclaimCompletedUploads(/*waitAll=*/true);
            DestroyUploadPools();
            if (m_uploadCommandPool != VK_NULL_HANDLE) {
                vkDestroyCommandPool(m_device, m_uploadCommandPool, nullptr);
                m_uploadCommandPool = VK_NULL_HANDLE;
            }
        }
        DestroyDeferredReleases();
        ++m_resourceEraseEpoch;  // every memoized resource pointer dies with the map
        m_textureResources.clear();
        m_aliveObjects.clear();
        m_storageImageTextures.clear();

        m_device = VK_NULL_HANDLE;
        m_physicalDevice = VK_NULL_HANDLE;
        m_allocator = nullptr;
        m_commandPool = VK_NULL_HANDLE;
        m_graphicsQueue = VK_NULL_HANDLE;
        m_currentFrameIndex = 0;
    }

    void VkTextureManager::BeginFrame(Uint32 frameIndex) {
        MOBILEGL_ASSERT(frameIndex < m_deferredReleases.size(),
                        "VkTextureManager::BeginFrame invalid frame index %u (size=%zu)",
                        frameIndex, m_deferredReleases.size());
        MOBILEGL_ASSERT(frameIndex < m_deferredViewReleases.size(),
                        "VkTextureManager::BeginFrame invalid deferred-view frame index %u (size=%zu)",
                        frameIndex, m_deferredViewReleases.size());
        m_currentFrameIndex = frameIndex;
        CollectDeferredReleases(frameIndex);
        ReclaimCompletedUploads();

        // Frame-boundary GC: every 64 frame boundaries (~1 s at 60 fps) bounds the reclaim
        // latency for dead textures regardless of draw traffic — workloads that churn
        // textures through clears/readbacks alone never reach the draw-gated
        // CollectGarbage. Must run after CollectDeferredReleases above: the prune defers
        // its releases into this frame's slot, which was just drained, so they are
        // destroyed only after the slot's fence has been waited again one full frame-ring
        // cycle from now (never while an in-flight frame may still reference them).
        constexpr Uint32 kGcFrameInterval = 64;
        ++m_gcFrameCounter;
        if (m_gcFrameCounter % kGcFrameInterval == 0) {
            PruneDeadTextures();
        }
    }

    void VkTextureManager::CollectAllDeferredReleases() {
        const SizeT frameCount = std::min(m_deferredReleases.size(), m_deferredViewReleases.size());
        for (SizeT frameIndex = 0; frameIndex < frameCount; ++frameIndex) {
            CollectDeferredReleases(static_cast<Uint32>(frameIndex));
        }
    }

    void VkTextureManager::EraseTrackedTexture(const TextureIdentity& identity) {
        m_viewRequestedImageFlags.erase(identity);
        m_viewRequestedFormats.erase(identity);
        auto resourceIt = m_textureResources.find(identity);
        if (resourceIt != m_textureResources.end()) {
            DeferResourceRelease(Move(resourceIt->second));
            m_textureResources.erase(resourceIt);
        }
        m_aliveObjects.erase(identity);
        m_storageImageTextures.erase(identity);
        // Invalidate every cross-draw sampled-texture memo: the erased
        // resource's address may be reused by a future emplace.
        ++m_resourceEraseEpoch;
    }

    void VkTextureManager::PruneStaleTextureAliases(MG_State::GLState::ITextureObject* texture) {
        if (texture == nullptr) {
            return;
        }

        Vector<TextureIdentity> staleAliases;
        for (auto it = m_aliveObjects.begin(); it != m_aliveObjects.end(); ++it) {
            if (it->first.texture != texture) {
                continue;
            }
            const auto liveTexture = it->second.lock();
            if (!liveTexture || liveTexture.get() != texture ||
                liveTexture->GetLifetimeId() != it->first.lifetimeId) {
                staleAliases.emplace_back(it->first);
            }
        }
        for (const auto& identity : staleAliases) {
            EraseTrackedTexture(identity);
        }
    }

    void VkTextureManager::BeginDrawSyncScope() {
        m_drawSyncedThisDraw.clear();
        m_drawSyncScopeActive = true;
    }

    void VkTextureManager::EndDrawSyncScope() {
        m_drawSyncScopeActive = false;
        m_drawSyncedThisDraw.clear();
    }

    VkTextureManager::TextureResource* VkTextureManager::SyncTextureAndGetDescriptor(MG_State::GLState::ITextureObject& textureOrView) {
        MOBILEGL_ASSERT(m_device != VK_NULL_HANDLE, "SyncTextureAndGetDescriptor: m_device == VK_NULL_HANDLE");

        // A GL texture view has no image of its own; it resolves to - and shares - the resource
        // of the texture whose storage it views, so that there is exactly one VkImage, one
        // tracked layout and one upload path per storage. Everything that makes the view a
        // different texture (format, level/layer window, sampled aspect) is applied where the
        // VkImageViews are built, keyed in alternateSampledViews / attachmentViews.
        MG_State::GLState::ITextureObject& texture = StorageTextureOf(textureOrView);
        if (&texture != &textureOrView) {
            NoteTextureViewImageRequirements(textureOrView, texture);
        }

        const TextureIdentity identity = MakeTextureIdentity(&texture);

        // Per-draw memo fast path (see BeginDrawSyncScope): a texture already fully
        // synced earlier in this draw cannot have changed since (no GL mutation runs
        // mid-SetupDraw), so skip the heavy SyncTexture work and hand back the
        // already-synced resource. Layout lives on the resource and is updated by the
        // transition path, so the short-circuited resource still reflects the truth.
        const Bool memoActive = m_drawSyncScopeActive;
        if (memoActive) {
            for (const DrawSyncedTexture& synced : m_drawSyncedThisDraw) {
                if (synced.identity == identity) {
                    if (synced.resource != nullptr && synced.resource->image != VK_NULL_HANDLE) {
                        return synced.resource;
                    }
                    break;  // resource unexpectedly gone -> fall through to a full sync
                }
            }
        }

        // Cross-draw memo probe (see SyncedTextureMemoEntry): skips both map
        // lookups and the (re)registration path for repeat-bound textures.
        TextureResource* resourcePtr = nullptr;
        for (Uint32 i = 0; i < kSyncedTextureMemoSize; ++i) {
            const SyncedTextureMemoEntry& memo = m_syncedTextureMemo[i];
            if (memo.texture == &texture && memo.lifetimeId == identity.lifetimeId &&
                memo.eraseEpoch == m_resourceEraseEpoch) {
                resourcePtr = memo.resource;
                break;
            }
        }

        if (resourcePtr == nullptr) {
            auto aliveIt = m_aliveObjects.find(identity);
            if (aliveIt != m_aliveObjects.end() && aliveIt->second.expired()) {
                EraseTrackedTexture(aliveIt->first);
                aliveIt = m_aliveObjects.end();
            }

            // Only (re)register and prune when this (texture, lifetime) pair is new: stale
            // aliases can only come into existence through an address reuse, which by
            // construction introduces a new identity. Doing this unconditionally made every
            // sampled-texture sync scan the entire alive-texture map per draw.
            if (aliveIt == m_aliveObjects.end()) {
                WeakPtr<MG_State::GLState::ITextureObject> aliveTexture;
                const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex());
                if (liveTexture && liveTexture.get() == &texture) {
                    aliveTexture = liveTexture;
                } else {
                    // The name lookup legally fails while the object is alive: the name was
                    // deleted with the texture still attached to an FBO (the attachment's
                    // SharedPtr keeps it alive), or the name was reused by a new texture, or
                    // this is a default texture object (name 0 lives outside the name map).
                    // Register through the object's own control block so the resource created
                    // below still participates in weak-expiry GC instead of becoming an
                    // orphan no reclamation path can reach until Shutdown.
                    aliveTexture = texture.weak_from_this();
                }
                if (!aliveTexture.expired()) {
                    m_aliveObjects[identity] = Move(aliveTexture);
                    PruneStaleTextureAliases(&texture);
                }
            }

            auto it = m_textureResources.find(identity);
            if (it == m_textureResources.end()) {
                TextureResource initial{};
                auto [insertIt, _] = m_textureResources.emplace(identity, Move(initial));
                it = insertIt;
            }
            resourcePtr = &(it->second);
            m_syncedTextureMemo[m_syncedTextureMemoNext] =
                SyncedTextureMemoEntry{&texture, identity.lifetimeId, m_resourceEraseEpoch, resourcePtr};
            m_syncedTextureMemoNext = (m_syncedTextureMemoNext + 1) % kSyncedTextureMemoSize;
        }

        if (!SyncTexture(texture, *resourcePtr)) {
            MGLOG_D("%s: Syncing texture %d failed", __func__, texture.GetExternalIndex());
            return nullptr;
        }

        if (memoActive) {
            Bool recorded = false;
            for (const DrawSyncedTexture& synced : m_drawSyncedThisDraw) {
                if (synced.identity == identity) {
                    recorded = true;
                    break;
                }
            }
            if (!recorded) {
                m_drawSyncedThisDraw.push_back({identity, resourcePtr});
            }
        }

        return resourcePtr;
    }

    VkImageView VkTextureManager::GetOrCreateViewAtMipLevel(MG_State::GLState::ITextureObject& texture, Uint32 mipLevel) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr || resource->image == VK_NULL_HANDLE) {
            return VK_NULL_HANDLE;
        }
        // A GL texture view shares this resource with the texture it views, so it must not touch
        // perMipViews: that vector is indexed by mip level alone and holds views built with the
        // STORAGE texture's format and full layer range. Route it through the keyed attachment
        // cache instead, where its own window is part of the key.
        if (texture.IsTextureView()) {
            const TextureViewWindow window = ResolveTextureViewWindow(texture, *resource);
            return GetOrCreateAttachmentViewAtMipLevel(texture, mipLevel, window.baseArrayLayer, window.layerCount,
                                                       window.viewType);
        }
        if (mipLevel >= resource->mipLevels) {
            return VK_NULL_HANDLE;
        }

        if (resource->perMipViews.size() != resource->mipLevels) {
            resource->perMipViews.resize(resource->mipLevels, VK_NULL_HANDLE);
        }

        VkImageView& perMipView = resource->perMipViews[mipLevel];
        if (perMipView != VK_NULL_HANDLE) {
            return perMipView;
        }

        perMipView = CreateImageView(resource->image, resource->format, resource->aspect, resource->viewType,
                                     mipLevel, 1, 0, resource->arrayLayers);
        if (perMipView == VK_NULL_HANDLE) {
            MGLOG_D("%s: CreateImageView failed for textureId=%d mipLevel=%u", __func__, texture.GetExternalIndex(), mipLevel);
            return VK_NULL_HANDLE;
        }

        return perMipView;
    }

    VkImageView VkTextureManager::GetOrCreateAttachmentViewAtMipLevel(MG_State::GLState::ITextureObject& texture,
                                                                      Uint32 mipLevel, Uint32 baseArrayLayer,
                                                                      Uint32 layerCount,
                                                                      VkImageViewType viewType) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr || resource->image == VK_NULL_HANDLE) {
            return VK_NULL_HANDLE;
        }
        // mipLevel and baseArrayLayer arrive in STORAGE space - every caller runs them through
        // ToStorageMipLevel / ToStorageArrayLayer at the GL attachment boundary. What a GL texture
        // view still contributes here is its own internal format, which may reinterpret the
        // storage's (GL 4.6 core table 8.21) and is what the attachment must actually be written
        // through.
        VkFormat viewFormatOverride = VK_FORMAT_UNDEFINED;
        if (texture.IsTextureView()) {
            viewFormatOverride = ResolveTextureViewWindow(texture, *resource).format;
        }
        if (mipLevel >= resource->mipLevels) {
            return VK_NULL_HANDLE;
        }
        // A 3D image has arrayLayers == 1 and keeps its GL layers on the z axis, so an attachment
        // view over it addresses SLICES through baseArrayLayer/layerCount: one slice for a
        // non-layered attachment (a 2D view) and the whole span for a layered one (a 2D_ARRAY view,
        // which is what a layered GL_TEXTURE_3D attachment plus a gl_Layer-writing geometry shader
        // means). BOTH spellings are legal only on a 2D-array-compatible image
        // (VUID-VkImageViewCreateInfo-image-04970 / -06723), which SyncTextureResource asks for and
        // may have had refused per format.
        //
        // The span is validated against the MIP's slice count, never against arrayLayers: a 3D
        // image's arrayLayers is 1 by construction, so measuring a layered span against it rejected
        // every layered 3D attachment - the null view that used to reach vkCreateFramebuffer.
        if (resource->viewType == VK_IMAGE_VIEW_TYPE_3D &&
            (viewType == VK_IMAGE_VIEW_TYPE_2D || viewType == VK_IMAGE_VIEW_TYPE_2D_ARRAY)) {
            const Uint32 sliceCount = std::max(resource->depth >> mipLevel, 1u);
            if ((resource->imageCreateFlags & VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT) == 0 ||
                layerCount == 0 || baseArrayLayer >= sliceCount || baseArrayLayer + layerCount > sliceCount) {
                // Not an error line: the render-pass builder turns the null view into one
                // MGLOG_E_ONCE and a skipped draw, which is the level this belongs at.
                MGLOG_D("%s: cannot name slice span [%u, %u) of 3D textureId=%d as viewType=%d (mip %u has %u "
                        "slices, 2D-array-compatible=%d)",
                        __func__, baseArrayLayer, baseArrayLayer + layerCount, texture.GetExternalIndex(),
                        static_cast<Int>(viewType), mipLevel, sliceCount,
                        (int)((resource->imageCreateFlags & VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT) != 0));
                return VK_NULL_HANDLE;
            }
        } else if (layerCount == 0 || baseArrayLayer >= resource->arrayLayers ||
                   baseArrayLayer + layerCount > resource->arrayLayers) {
            MGLOG_D("%s: invalid layer span [%u, %u) for textureId=%d arrayLayers=%u",
                    __func__, baseArrayLayer, baseArrayLayer + layerCount, texture.GetExternalIndex(),
                    resource->arrayLayers);
            return VK_NULL_HANDLE;
        }

        const Bool framebufferSrgbEnabled =
            MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb);
        const VkFormat baseAttachmentFormat =
            viewFormatOverride != VK_FORMAT_UNDEFINED ? viewFormatOverride : resource->format;
        const VkFormat attachmentFormat =
            ResolveSrgbAttachmentWriteFormat(baseAttachmentFormat, framebufferSrgbEnabled);

        // The shortcut back to the per-mip vector is only sound for the storage texture itself;
        // for a view every field below is part of what distinguishes it from its parent.
        if (viewFormatOverride == VK_FORMAT_UNDEFINED && attachmentFormat == resource->format &&
            baseArrayLayer == 0 && layerCount == resource->arrayLayers && viewType == resource->viewType) {
            return GetOrCreateViewAtMipLevel(texture, mipLevel);
        }

        const TextureResource::AttachmentViewKey key{
            .mipLevel = mipLevel,
            .baseArrayLayer = baseArrayLayer,
            .layerCount = layerCount,
            .viewType = viewType,
            .viewFormat = attachmentFormat,
        };
        auto it = resource->attachmentViews.find(key);
        if (it == resource->attachmentViews.end()) {
            it = resource->attachmentViews.emplace(key, VK_NULL_HANDLE).first;
        }
        VkImageView& attachmentView = it->second;
        if (attachmentView != VK_NULL_HANDLE) {
            return attachmentView;
        }

        attachmentView = CreateImageView(resource->image, attachmentFormat, resource->aspect, viewType,
                                         mipLevel, 1, baseArrayLayer, layerCount);
        if (attachmentView == VK_NULL_HANDLE) {
            MGLOG_D("%s: CreateImageView failed for textureId=%d mipLevel=%u baseArrayLayer=%u layerCount=%u viewType=%d",
                    __func__, texture.GetExternalIndex(), mipLevel, baseArrayLayer, layerCount, static_cast<Int>(viewType));
            resource->attachmentViews.erase(it);
            return VK_NULL_HANDLE;
        }

        return attachmentView;
    }

    VkImageView VkTextureManager::GetOrCreateSampledViewAtMipLevel(MG_State::GLState::ITextureObject& texture,
                                                                   Uint32 mipLevel) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr || resource->image == VK_NULL_HANDLE) {
            return VK_NULL_HANDLE;
        }
        // As in GetOrCreateViewAtMipLevel: perMipSampledViews belongs to the storage texture's
        // own format and aspect, so a GL view has to go to the keyed cache.
        if (texture.IsTextureView()) {
            if (mipLevel >= resource->mipLevels) {
                return VK_NULL_HANDLE;
            }
            TextureViewWindow window = ResolveTextureViewWindow(texture, *resource);
            // Storage space already (see ToStorageMipLevel); only the level COUNT narrows.
            window.baseMipLevel = mipLevel;
            window.levelCount = 1;
            return GetOrCreateWindowedSampledView(texture, *resource, window);
        }
        if (mipLevel >= resource->mipLevels) {
            return VK_NULL_HANDLE;
        }

        if (resource->perMipSampledViews.size() != resource->mipLevels) {
            resource->perMipSampledViews.resize(resource->mipLevels, VK_NULL_HANDLE);
        }

        VkImageView& perMipSampledView = resource->perMipSampledViews[mipLevel];
        if (perMipSampledView != VK_NULL_HANDLE) {
            return perMipSampledView;
        }

        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
        const VkComponentMapping sampledComponents = ResolveSampledViewComponents(texture, formatInfo);
        const VkImageAspectFlags sampledAspect =
            ResolveSampledImageViewAspectMask(resource->aspect, texture.GetDepthStencilTextureMode());
        perMipSampledView = CreateImageView(resource->image, resource->format, sampledAspect, resource->viewType,
                                            mipLevel, 1, 0, resource->arrayLayers, &sampledComponents);
        if (perMipSampledView == VK_NULL_HANDLE) {
            MGLOG_D("%s: CreateImageView failed for textureId=%d mipLevel=%u", __func__, texture.GetExternalIndex(),
                    mipLevel);
            return VK_NULL_HANDLE;
        }

        return perMipSampledView;
    }

    // Builds (and caches) one sampled VkImageView over `resource`'s image for an arbitrary
    // window - the shared back end of every GL-texture-view sampled path. Keyed by the whole
    // window, which is what keeps a D24S8's depth-aspect view and its stencil-aspect view apart
    // in the same cache while both name the same image, the same levels and the same layers.
    VkImageView VkTextureManager::GetOrCreateWindowedSampledView(MG_State::GLState::ITextureObject& texture,
                                                                 TextureResource& resource,
                                                                 const TextureViewWindow& window) {
        const TextureResource::SampledImageViewKey key{
            .baseMipLevel = window.baseMipLevel,
            .levelCount = window.levelCount,
            .baseArrayLayer = window.baseArrayLayer,
            .layerCount = window.layerCount,
            .viewType = window.viewType,
            .format = window.format,
            .aspect = window.sampledAspect,
            .componentSwizzle = PackComponentSwizzle(window.components),
        };
        const auto existing = resource.alternateSampledViews.find(key);
        if (existing != resource.alternateSampledViews.end()) {
            return existing->second;
        }

        if (window.format != resource.format &&
            (resource.imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
            MGLOG_E_ONCE("%s: textureId=%d needs a mutable-format image to be viewed as format=%d "
                         "(image format=%d)",
                         __func__, texture.GetExternalIndex(), static_cast<Int>(window.format),
                         static_cast<Int>(resource.format));
            return VK_NULL_HANDLE;
        }

        const VkImageView view =
            CreateImageView(resource.image, window.format, window.sampledAspect, window.viewType,
                            window.baseMipLevel, window.levelCount, window.baseArrayLayer, window.layerCount,
                            &window.components);
        if (view == VK_NULL_HANDLE) {
            MGLOG_E_ONCE("%s: failed to create sampled view for textureId=%d format=%d aspect=0x%x "
                         "mips=[%u,%u) layers=[%u,%u)",
                         __func__, texture.GetExternalIndex(), static_cast<Int>(window.format),
                         static_cast<Uint32>(window.sampledAspect), window.baseMipLevel,
                         window.baseMipLevel + window.levelCount, window.baseArrayLayer,
                         window.baseArrayLayer + window.layerCount);
            return VK_NULL_HANDLE;
        }
        resource.alternateSampledViews.emplace(key, view);
        return view;
    }

    VkImageView VkTextureManager::GetOrCreateSampledImageView(MG_State::GLState::ITextureObject& texture,
                                                               VkFormat format) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr || resource->image == VK_NULL_HANDLE) {
            return VK_NULL_HANDLE;
        }

        // A GL texture view never has a sampledView of its own on this resource - that one
        // belongs to the storage texture, with the storage texture's format, level range and
        // depth/stencil aspect. The window is the view's whole identity, so it always goes to the
        // keyed cache, even when the requested format happens to match the image's.
        if (texture.IsTextureView()) {
            TextureViewWindow window = ResolveTextureViewWindow(texture, *resource);
            if (format != VK_FORMAT_UNDEFINED) {
                window.format = format;
            }
            return GetOrCreateWindowedSampledView(texture, *resource, window);
        }

        if (resource->sampledView == VK_NULL_HANDLE) {
            return VK_NULL_HANDLE;
        }
        if (format == VK_FORMAT_UNDEFINED || format == resource->format) {
            return resource->sampledView;
        }
        if (!AreSampledImageViewFormatsCompatible(resource->format, format)) {
            MGLOG_E_ONCE("%s: incompatible sampled image view format=%d for textureId=%d imageFormat=%d",
                    __func__, static_cast<Int>(format), texture.GetExternalIndex(),
                    static_cast<Int>(resource->format));
            return VK_NULL_HANDLE;
        }
        if ((resource->imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
            MGLOG_E_ONCE("%s: textureId=%d needs mutable image format=%d for sampled view format=%d",
                    __func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
                    static_cast<Int>(format));
            return VK_NULL_HANDLE;
        }

        const TextureResource::SampledImageViewKey key{
            .baseMipLevel = resource->sampledBaseMipLevel,
            .levelCount = resource->sampledLevelCount,
            .baseArrayLayer = 0,
            .layerCount = resource->arrayLayers,
            .viewType = resource->viewType,
            .format = format,
            .aspect = VK_IMAGE_ASPECT_COLOR_BIT,
            .componentSwizzle = PackComponentSwizzle(
                ResolveSampledViewComponents(texture, ResolveTextureFormatInfo(texture.GetFormat()))),
        };
        const auto existing = resource->alternateSampledViews.find(key);
        if (existing != resource->alternateSampledViews.end()) {
            return existing->second;
        }

        VkFormatProperties formatProperties{};
        vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
        if ((formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) == 0) {
            MGLOG_E_ONCE("%s: sampled image view format=%d lacks VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT "
                    "for textureId=%d (available=0x%x)",
                    __func__, static_cast<Int>(format), texture.GetExternalIndex(),
                    static_cast<Uint32>(formatProperties.optimalTilingFeatures));
            return VK_NULL_HANDLE;
        }

        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
        const VkComponentMapping sampledComponents = ResolveSampledViewComponents(texture, formatInfo);
        const VkImageView view = CreateImageView(
            resource->image, format, VK_IMAGE_ASPECT_COLOR_BIT, resource->viewType,
            resource->sampledBaseMipLevel, resource->sampledLevelCount, 0, resource->arrayLayers,
            &sampledComponents, VK_IMAGE_USAGE_SAMPLED_BIT);
        if (view == VK_NULL_HANDLE) {
            MGLOG_E_ONCE("%s: failed to create sampled image view textureId=%d imageFormat=%d viewFormat=%d",
                    __func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
                    static_cast<Int>(format));
            return VK_NULL_HANDLE;
        }

        resource->alternateSampledViews.emplace(key, view);
        MGLOG_D("%s: created sampled image view textureId=%d imageFormat=%d viewFormat=%d mip=[%u,%u)",
                __func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
                static_cast<Int>(format), resource->sampledBaseMipLevel,
                resource->sampledBaseMipLevel + resource->sampledLevelCount);
        return view;
    }

    VkImageView VkTextureManager::GetOrCreateStorageImageView(MG_State::GLState::ITextureObject& texture,
                                                               Uint32 mipLevel, VkFormat format,
                                                               Bool layered, Int32 layer) {
        // mipLevel and layer arrive in STORAGE space; ResolveStorageImageDescriptor converts
        // the glBindImageTexture values with ToStorageMipLevel / ToStorageArrayLayer.
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr || resource->image == VK_NULL_HANDLE || mipLevel >= resource->mipLevels ||
            resource->sampleCount != VK_SAMPLE_COUNT_1_BIT ||
            (resource->aspect & VK_IMAGE_ASPECT_COLOR_BIT) == 0) {
            return VK_NULL_HANDLE;
        }

        if (format == VK_FORMAT_UNDEFINED) {
            format = resource->format;
        }
        if (!AreStorageImageViewFormatsCompatible(resource->format, format)) {
            MGLOG_E_ONCE("%s: incompatible storage image view format=%d for textureId=%d imageFormat=%d",
                    __func__, static_cast<Int>(format), texture.GetExternalIndex(),
                    static_cast<Int>(resource->format));
            return VK_NULL_HANDLE;
        }
        if (format != resource->format &&
            (resource->imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
            MGLOG_E_ONCE("%s: textureId=%d needs mutable image format=%d for storage view format=%d",
                    __func__, texture.GetExternalIndex(), static_cast<Int>(resource->format),
                    static_cast<Int>(format));
            return VK_NULL_HANDLE;
        }

        // A GL texture view opens onto a WINDOW of the storage's layers; a layered image
        // binding of it must not reach past that window into the parent's other layers.
        Uint32 baseArrayLayer = ToStorageArrayLayer(&texture, 0);
        Uint32 layerCount = texture.IsTextureView()
                                ? std::min(static_cast<Uint32>(texture.GetViewNumLayers()),
                                           resource->arrayLayers - baseArrayLayer)
                                : resource->arrayLayers;
        VkImageViewType viewType = resource->viewType;
        if (!layered) {
            switch (resource->viewType) {
            case VK_IMAGE_VIEW_TYPE_1D_ARRAY:
                viewType = VK_IMAGE_VIEW_TYPE_1D;
                break;
            case VK_IMAGE_VIEW_TYPE_2D_ARRAY:
            case VK_IMAGE_VIEW_TYPE_CUBE:
            case VK_IMAGE_VIEW_TYPE_CUBE_ARRAY:
                viewType = VK_IMAGE_VIEW_TYPE_2D;
                break;
            case VK_IMAGE_VIEW_TYPE_3D:
                MGLOG_E_ONCE("%s: non-layered 3D storage views are unsupported for textureId=%d",
                        __func__, texture.GetExternalIndex());
                return VK_NULL_HANDLE;
            default:
                break;
            }

            if (viewType != resource->viewType) {
                if (layer < 0 || static_cast<Uint32>(layer) >= resource->arrayLayers) {
                    MGLOG_E_ONCE("%s: storage image layer=%d is out of range for textureId=%d arrayLayers=%u",
                            __func__, layer, texture.GetExternalIndex(), resource->arrayLayers);
                    return VK_NULL_HANDLE;
                }
                baseArrayLayer = static_cast<Uint32>(layer);
                layerCount = 1;
            }
        }

        const Bool isFullResourceView = baseArrayLayer == 0 && layerCount == resource->arrayLayers &&
                                        viewType == resource->viewType;
        if (format == resource->format && isFullResourceView && !texture.IsTextureView()) {
            return GetOrCreateViewAtMipLevel(texture, mipLevel);
        }

        const TextureResource::StorageImageViewKey key{
            .mipLevel = mipLevel,
            .baseArrayLayer = baseArrayLayer,
            .layerCount = layerCount,
            .viewType = viewType,
            .format = format,
        };
        auto it = resource->storageImageViews.find(key);
        if (it != resource->storageImageViews.end()) {
            return it->second;
        }

        VkFormatFeatureFlags requiredFormatFeatures = VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT;
        if (format != resource->format &&
            (format == VK_FORMAT_R32_UINT || format == VK_FORMAT_R32_SINT)) {
            requiredFormatFeatures |= VK_FORMAT_FEATURE_STORAGE_IMAGE_ATOMIC_BIT;
        }
        VkFormatProperties formatProperties{};
        vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
        if ((formatProperties.optimalTilingFeatures & requiredFormatFeatures) != requiredFormatFeatures) {
            MGLOG_E_ONCE("%s: storage image view format=%d lacks required features=0x%x for textureId=%d "
                    "(available=0x%x)",
                    __func__, static_cast<Int>(format), static_cast<Uint32>(requiredFormatFeatures),
                    texture.GetExternalIndex(), static_cast<Uint32>(formatProperties.optimalTilingFeatures));
            return VK_NULL_HANDLE;
        }

        const VkImageView view = CreateImageView(resource->image, format, VK_IMAGE_ASPECT_COLOR_BIT, viewType,
                                                 mipLevel, 1, baseArrayLayer, layerCount, nullptr,
                                                 VK_IMAGE_USAGE_STORAGE_BIT);
        if (view == VK_NULL_HANDLE) {
            MGLOG_E_ONCE("%s: failed to create storage image view for textureId=%d mip=%u imageFormat=%d viewFormat=%d",
                    __func__, texture.GetExternalIndex(), mipLevel, static_cast<Int>(resource->format),
                    static_cast<Int>(format));
            return VK_NULL_HANDLE;
        }
        resource->storageImageViews.emplace(key, view);
        MGLOG_D("%s: created storage image view textureId=%d mip=%u imageFormat=%d viewFormat=%d",
                __func__, texture.GetExternalIndex(), mipLevel, static_cast<Int>(resource->format),
                static_cast<Int>(format));
        return view;
    }

    void VkTextureManager::StampTextureRecordingUse(MG_State::GLState::ITextureObject* texture) {
        if (texture == nullptr) {
            return;
        }
        auto it = m_textureResources.find(MakeTextureIdentity(texture));
        if (it != m_textureResources.end()) {
            it->second.lastRecordingGeneration = m_recordingGeneration;
        }
    }

    void VkTextureManager::UpdateTrackedImageLayout(MG_State::GLState::ITextureObject* texture, VkImageLayout newLayout) {
        MOBILEGL_ASSERT(texture != nullptr, "UpdateTrackedImageLayout: texture is null");
        auto it = m_textureResources.find(MakeTextureIdentity(texture));
        MOBILEGL_ASSERT(it != m_textureResources.end(),
                        "UpdateTrackedImageLayout: textureId=%d has no tracked resource", texture->GetExternalIndex());
        MOBILEGL_ASSERT(it->second.image != VK_NULL_HANDLE,
                        "UpdateTrackedImageLayout: textureId=%d has null image", texture->GetExternalIndex());
        it->second.layout = newLayout;
    }

    void VkTextureManager::UpdateTrackedImageLayoutAfterAttachmentWrite(VkCommandBuffer commandBuffer,
                                                                        MG_State::GLState::ITextureObject* texture,
                                                                        Uint32 writtenMipLevel,
                                                                        VkImageLayout newLayout) {
        MOBILEGL_ASSERT(texture != nullptr, "UpdateTrackedImageLayoutAfterAttachmentWrite: texture is null");
        auto it = m_textureResources.find(MakeTextureIdentity(texture));
        MOBILEGL_ASSERT(it != m_textureResources.end(),
                        "UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d has no tracked resource",
                        texture->GetExternalIndex());

        auto& resource = it->second;
        MOBILEGL_ASSERT(resource.image != VK_NULL_HANDLE,
                        "UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d has null image",
                        texture->GetExternalIndex());
        MOBILEGL_ASSERT(writtenMipLevel < resource.mipLevels,
                        "UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d mipLevel=%u out of range %u",
                        texture->GetExternalIndex(), writtenMipLevel, resource.mipLevels);
        // Pre-pass stream bookkeeping: the render pass that just ended wrote this image.
        StampResourceRecordingUse(resource);

        if (resource.layout != newLayout && resource.mipLevels > 1) {
            VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
            VkAccessFlags srcAccessMask = 0;
            GetImageTransitionSourceState(resource.layout, srcStageMask, srcAccessMask);

            VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
            VkAccessFlags dstAccessMask = 0;
            GetImageTransitionDestinationState(newLayout, dstStageMask, dstAccessMask);

            if (writtenMipLevel > 0) {
                VkImageLayout lowerMipLayout = resource.layout;
                const Bool lowerTransitioned = TransitionImageLayout(
                    commandBuffer, resource.image, lowerMipLayout, newLayout,
                    srcStageMask, dstStageMask, srcAccessMask, dstAccessMask,
                    resource.aspect, 0, writtenMipLevel);
                MOBILEGL_ASSERT(lowerTransitioned,
                                "UpdateTrackedImageLayoutAfterAttachmentWrite: failed to transition lower mip levels for textureId=%d",
                                texture->GetExternalIndex());
            }

            const Uint32 upperBaseMipLevel = writtenMipLevel + 1;
            if (upperBaseMipLevel < resource.mipLevels) {
                VkImageLayout upperMipLayout = resource.layout;
                const Bool upperTransitioned = TransitionImageLayout(
                    commandBuffer, resource.image, upperMipLayout, newLayout,
                    srcStageMask, dstStageMask, srcAccessMask, dstAccessMask,
                    resource.aspect, upperBaseMipLevel, resource.mipLevels - upperBaseMipLevel);
                MOBILEGL_ASSERT(upperTransitioned,
                                "UpdateTrackedImageLayoutAfterAttachmentWrite: failed to transition upper mip levels for textureId=%d",
                                texture->GetExternalIndex());
            }
        }

        resource.layout = newLayout;
    }

    Bool VkTextureManager::TransitionTextureForSampling(VkCommandBuffer commandBuffer, MG_State::GLState::ITextureObject& texture) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr) {
            return false;
        }
        if (IsValidSampledImageLayout(resource->layout)) {
            return true;
        }
        if (resource->layout == VK_IMAGE_LAYOUT_UNDEFINED) {
            MGLOG_W_ONCE("TransitionTextureForSampling: textureId=%d is still in VK_IMAGE_LAYOUT_UNDEFINED before sampling",
                    texture.GetExternalIndex());
        }

        VkImageLayout targetLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
        VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        VkAccessFlags srcAccessMask = 0;
        if ((resource->aspect & VK_IMAGE_ASPECT_COLOR_BIT) != 0) {
            MOBILEGL_ASSERT(resource->layout == VK_IMAGE_LAYOUT_UNDEFINED ||
                            resource->layout == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
                            "TransitionTextureForSampling: unsupported color layout=%d for textureId=%d",
                            static_cast<Int>(resource->layout), texture.GetExternalIndex());
            if (resource->layout == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL) {
                srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
                srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
            }
            targetLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
        } else if ((resource->aspect & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT)) != 0) {
            MOBILEGL_ASSERT(resource->layout == VK_IMAGE_LAYOUT_UNDEFINED ||
                            resource->layout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
                            "TransitionTextureForSampling: unsupported depth/stencil layout=%d for textureId=%d",
                            static_cast<Int>(resource->layout), texture.GetExternalIndex());
            if (resource->layout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
                srcStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
                srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
            }
            targetLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL;
        } else {
            MOBILEGL_ASSERT(false, "TransitionTextureForSampling: unsupported aspect mask=0x%x for textureId=%d",
                            static_cast<Uint32>(resource->aspect), texture.GetExternalIndex());
        }

        const Bool ok = TransitionImageLayout(commandBuffer, resource->image, resource->layout, targetLayout, srcStageMask,
                                              s_sampledReadStages, srcAccessMask,
                                              VK_ACCESS_SHADER_READ_BIT, resource->aspect, 0, resource->mipLevels);
        MOBILEGL_ASSERT(ok, "TransitionTextureForSampling: transition failed for textureId=%d", texture.GetExternalIndex());
        // Pre-pass stream bookkeeping: a command referencing the image was recorded.
        StampResourceRecordingUse(*resource);
        return ok;
    }

    Bool VkTextureManager::TransitionTextureForStorageImage(VkCommandBuffer commandBuffer,
                                                            MG_State::GLState::ITextureObject& texture) {
        TextureResource* resource = SyncTextureAndGetDescriptor(texture);
        if (resource == nullptr) {
            return false;
        }
        if (resource->sampleCount != VK_SAMPLE_COUNT_1_BIT) {
            MGLOG_D("TransitionTextureForStorageImage: multisample textureId=%d is not exposed as a storage image",
                    texture.GetExternalIndex());
            return false;
        }
        if (resource->layout == VK_IMAGE_LAYOUT_GENERAL) {
            return true;
        }

        VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
        VkAccessFlags srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT;
        GetImageTransitionSourceState(resource->layout, srcStageMask, srcAccessMask);

        const Bool ok = TransitionImageLayout(commandBuffer, resource->image, resource->layout,
                                              VK_IMAGE_LAYOUT_GENERAL, srcStageMask,
                                              VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, srcAccessMask,
                                              VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
                                              resource->aspect, 0, resource->mipLevels);
        MOBILEGL_ASSERT(ok, "TransitionTextureForStorageImage: transition failed for textureId=%d",
                        texture.GetExternalIndex());
        // Pre-pass stream bookkeeping: a command referencing the image was recorded.
        StampResourceRecordingUse(*resource);
        return ok;
    }

    Bool VkTextureManager::SnapshotTextureForSampling(VkCommandBuffer commandBuffer,
                                                      MG_State::GLState::ITextureObject& texture,
                                                      SamplerNumericDomain numericDomain,
                                                      VkPipelineStageFlags consumerShaderStageMask,
                                                      SampledTextureSnapshot& outSnapshot) {
        outSnapshot = {};
        TextureResource* source = SyncTextureAndGetDescriptor(texture);
        if (source == nullptr || source->image == VK_NULL_HANDLE || source->sampleCount != VK_SAMPLE_COUNT_1_BIT ||
            source->sampledLevelCount == 0) {
            return false;
        }

        const VkFormat sampledFormat = ResolveSampledImageViewFormat(source->format, numericDomain);
        if (sampledFormat == VK_FORMAT_UNDEFINED ||
            !AreSampledImageViewFormatsCompatible(source->format, sampledFormat)) {
            MGLOG_E_ONCE("SnapshotTextureForSampling: textureId=%d cannot create sampled view format=%d from image format=%d",
                         texture.GetExternalIndex(), static_cast<Int>(sampledFormat), static_cast<Int>(source->format));
            return false;
        }
        if (sampledFormat != source->format &&
            (source->imageCreateFlags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) == 0) {
            MGLOG_E_ONCE("SnapshotTextureForSampling: textureId=%d needs unavailable mutable image format=%d for sampled view=%d",
                         texture.GetExternalIndex(), static_cast<Int>(source->format), static_cast<Int>(sampledFormat));
            return false;
        }

        VkImageType imageType = VK_IMAGE_TYPE_2D;
        switch (source->viewType) {
        case VK_IMAGE_VIEW_TYPE_1D:
        case VK_IMAGE_VIEW_TYPE_1D_ARRAY:
            imageType = VK_IMAGE_TYPE_1D;
            break;
        case VK_IMAGE_VIEW_TYPE_3D:
            imageType = VK_IMAGE_TYPE_3D;
            break;
        default:
            break;
        }

        TextureResource snapshot{};
        VkImageCreateInfo imageInfo{};
        imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
        imageInfo.flags = source->imageCreateFlags;
        imageInfo.imageType = imageType;
        imageInfo.extent = {source->extent.width, source->extent.height, source->depth};
        imageInfo.mipLevels = source->mipLevels;
        imageInfo.arrayLayers = source->arrayLayers;
        imageInfo.format = source->format;
        imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
        imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
        imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
        imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

        // Keep the temporary's view-format list just as narrow as the source's sampler use. This
        // has no storage-image usage, so unlike an app image binding the exact list is knowable.
        Vector<VkFormat> viewFormats;
        VkImageFormatListCreateInfo formatListInfo{};
        if (m_imageFormatListSupported && (imageInfo.flags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) != 0) {
            viewFormats.push_back(source->format);
            if (sampledFormat != source->format) {
                viewFormats.push_back(sampledFormat);
            }
            formatListInfo.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO;
            formatListInfo.viewFormatCount = static_cast<Uint32>(viewFormats.size());
            formatListInfo.pViewFormats = viewFormats.data();
            imageInfo.pNext = &formatListInfo;
        }

        VmaAllocationCreateInfo allocationInfo{};
        allocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE;
        allocationInfo.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
        const VkResult createResult =
            vmaCreateImage(m_allocator, &imageInfo, &allocationInfo, &snapshot.image, &snapshot.allocation, nullptr);
        if (createResult != VK_SUCCESS) {
            MGLOG_E_ONCE("SnapshotTextureForSampling: vmaCreateImage failed result=%d textureId=%d", createResult,
                         texture.GetExternalIndex());
            return false;
        }

        snapshot.extent = source->extent;
        snapshot.depth = source->depth;
        snapshot.arrayLayers = source->arrayLayers;
        snapshot.mipLevels = source->mipLevels;
        snapshot.sampledBaseMipLevel = source->sampledBaseMipLevel;
        snapshot.sampledLevelCount = source->sampledLevelCount;
        snapshot.format = source->format;
        snapshot.aspect = source->aspect;
        snapshot.viewType = source->viewType;
        snapshot.sampleCount = VK_SAMPLE_COUNT_1_BIT;
        snapshot.imageCreateFlags = imageInfo.flags;
        snapshot.usageFlags = imageInfo.usage;

        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
        const VkComponentMapping sampledComponents = ResolveSampledViewComponents(texture, formatInfo);
        const VkImageAspectFlags sampledAspect =
            ResolveSampledImageViewAspectMask(snapshot.aspect, texture.GetDepthStencilTextureMode());
        snapshot.sampledView = CreateImageView(snapshot.image, sampledFormat, sampledAspect, snapshot.viewType,
                                               snapshot.sampledBaseMipLevel, snapshot.sampledLevelCount, 0,
                                               snapshot.arrayLayers, &sampledComponents);
        if (snapshot.sampledView == VK_NULL_HANDLE) {
            MGLOG_E_ONCE("SnapshotTextureForSampling: failed to create sampled view textureId=%d", texture.GetExternalIndex());
            return false;
        }

        VkPipelineStageFlags sourceStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        VkAccessFlags sourceAccessMask = 0;
        const VkImageLayout sourceLayout = source->layout;
        GetImageTransitionSourceState(sourceLayout, sourceStageMask, sourceAccessMask);
        if (!TransitionImageLayout(commandBuffer, source->image, source->layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
                                   sourceStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, sourceAccessMask,
                                   VK_ACCESS_TRANSFER_READ_BIT, source->aspect, 0, source->mipLevels) ||
            !TransitionImageLayout(commandBuffer, snapshot.image, snapshot.layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                                   VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0,
                                   VK_ACCESS_TRANSFER_WRITE_BIT, snapshot.aspect, snapshot.sampledBaseMipLevel,
                                   snapshot.sampledLevelCount)) {
            return false;
        }

        Vector<VkImageCopy> copyRegions;
        copyRegions.reserve(snapshot.sampledLevelCount);
        for (Uint32 level = snapshot.sampledBaseMipLevel;
             level < snapshot.sampledBaseMipLevel + snapshot.sampledLevelCount; ++level) {
            VkImageCopy copy{};
            copy.srcSubresource = {source->aspect, level, 0, source->arrayLayers};
            copy.dstSubresource = {snapshot.aspect, level, 0, snapshot.arrayLayers};
            copy.extent = {std::max(source->extent.width >> level, 1u),
                           std::max(source->extent.height >> level, 1u),
                           std::max(source->depth >> level, 1u)};
            copyRegions.push_back(copy);
        }
        vkCmdCopyImage(commandBuffer, source->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, snapshot.image,
                       VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, static_cast<Uint32>(copyRegions.size()), copyRegions.data());

        if (!TransitionImageLayout(commandBuffer, snapshot.image, snapshot.layout,
                                   ResolveSampledReadOnlyLayout(snapshot.aspect), VK_PIPELINE_STAGE_TRANSFER_BIT,
                                   consumerShaderStageMask, VK_ACCESS_TRANSFER_WRITE_BIT,
                                   VK_ACCESS_SHADER_READ_BIT, snapshot.aspect, snapshot.sampledBaseMipLevel,
                                   snapshot.sampledLevelCount) ||
            !TransitionImageLayout(commandBuffer, source->image, source->layout, sourceLayout,
                                   VK_PIPELINE_STAGE_TRANSFER_BIT, consumerShaderStageMask,
                                   VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
                                   source->aspect, 0, source->mipLevels)) {
            return false;
        }

        StampResourceRecordingUse(*source);
        outSnapshot = {.imageView = snapshot.sampledView, .layout = snapshot.layout};
        DeferResourceRelease(Move(snapshot));
        return true;
    }

    void VkTextureManager::MarkStorageImageTexture(MG_State::GLState::ITextureObject& texture) {
        m_storageImageTextures.insert(MakeTextureIdentity(&texture));
    }

    Bool VkTextureManager::NeedsStorageUsageUpgrade(MG_State::GLState::ITextureObject& texture) const {
        const TextureIdentity identity = MakeTextureIdentity(&texture);
        if (m_storageImageTextures.find(identity) == m_storageImageTextures.end()) {
            return false;
        }
        const auto it = m_textureResources.find(identity);
        // No image yet: the first sync creates it with STORAGE straight away, so there is nothing
        // to preserve and nothing to order against.
        return it != m_textureResources.end() && it->second.image != VK_NULL_HANDLE &&
               !it->second.storageUsageResolved;
    }

    Bool VkTextureManager::NeedsMipChainGrowth(MG_State::GLState::ITextureObject& texture) const {
        const TextureIdentity identity = MakeTextureIdentity(&texture);
        const auto it = m_textureResources.find(identity);
        // No image yet: the first sync sizes the chain from the levels the texture already
        // defines, so nothing is recreated and there is nothing to order against.
        if (it == m_textureResources.end() || it->second.image == VK_NULL_HANDLE) {
            return false;
        }
        const TextureResource& resource = it->second;
        const IntVec3 extent = {static_cast<Int>(resource.extent.width), static_cast<Int>(resource.extent.height),
                                static_cast<Int>(resource.depth)};
        return resource.mipLevels < ComputeFullMipLevelCount(extent);
    }

    Bool VkTextureManager::NeedsStorageImagePreparation(MG_State::GLState::ITextureObject& texture) const {
        const TextureIdentity identity = MakeTextureIdentity(&texture);
        const auto it = m_textureResources.find(identity);
        if (it == m_textureResources.end()) {
            return true;
        }
        const TextureResource& resource = it->second;
        if (resource.image == VK_NULL_HANDLE || resource.layout != VK_IMAGE_LAYOUT_GENERAL) {
            return true;
        }
        // The image predates this texture's first image-unit binding, so it was created without
        // STORAGE usage and has to be recreated - which is illegal inside a render pass.
        if (!resource.storageUsageResolved &&
            m_storageImageTextures.find(identity) != m_storageImageTextures.end()) {
            return true;
        }
        // Mirror SyncTexture's cross-draw skip condition: any version drift means the sync
        // path may upload or rebuild, both of which need the render pass ended first.
        const auto* mipTexture = MG_State::GLState::AsMipmapTexture(&texture);
        const Uint32 mipLevelCount = mipTexture != nullptr ? mipTexture->GetMipmapLevelCount() : 0u;
        return resource.syncedContentVersion != texture.GetContentVersion() ||
               resource.syncedShapeVersion != texture.GetShapeVersion() ||
               resource.syncedTextureParamsVersion != texture.GetTextureParamsVersion() ||
               resource.syncedMipLevelCount != mipLevelCount;
    }

    Bool VkTextureManager::TransitionImageLayout(VkCommandBuffer commandBuffer, VkImage image,
                                                 VkImageLayout& trackedLayout, VkImageLayout newLayout,
                                                 VkPipelineStageFlags srcStageMask, VkPipelineStageFlags dstStageMask,
                                                 VkAccessFlags srcAccessMask, VkAccessFlags dstAccessMask,
                                                 VkImageAspectFlags aspectMask, Uint32 baseMipLevel,
                                                 Uint32 levelCount) {
        MOBILEGL_ASSERT(image != VK_NULL_HANDLE, "TransitionImageLayout: m_image == VK_NULL_HANDLE");
        MOBILEGL_ASSERT(!((dstAccessMask & VK_ACCESS_TRANSFER_READ_BIT) != 0 &&
                          (dstStageMask & VK_PIPELINE_STAGE_TRANSFER_BIT) == 0),
                        "TransitionImageLayout: invalid dstAccess/dstStage pair (dstAccess=0x%x, dstStage=0x%x, oldLayout=%d, newLayout=%d)",
                        static_cast<Uint32>(dstAccessMask), static_cast<Uint32>(dstStageMask), static_cast<Int>(trackedLayout),
                        static_cast<Int>(newLayout));

        if (trackedLayout == newLayout) {
            return true;
        }

        VkImageMemoryBarrier barrier{};
        barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
        barrier.srcAccessMask = srcAccessMask;
        barrier.dstAccessMask = dstAccessMask;
        barrier.oldLayout = trackedLayout;
        barrier.newLayout = newLayout;
        barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
        barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
        barrier.image = image;
        barrier.subresourceRange.aspectMask = aspectMask;
        barrier.subresourceRange.baseMipLevel = baseMipLevel;
        barrier.subresourceRange.levelCount = levelCount;
        barrier.subresourceRange.baseArrayLayer = 0;
        // Every layer, always - see the declaration for why layout tracking leaves no other
        // correct answer. VK_REMAINING_ARRAY_LAYERS rather than the image's own `arrayLayers`
        // because those are not the same number for a 3D image: MobileGL creates 3D images
        // 2D_ARRAY_COMPATIBLE and their arrayLayers is 1, which today Vulkan reads as "all depth
        // slices" but will read as "depth slice 0" once VK_KHR_maintenance9 is enabled. The
        // validation layer warns about that literal 1 by name.
        barrier.subresourceRange.layerCount = VK_REMAINING_ARRAY_LAYERS;
        vkCmdPipelineBarrier(commandBuffer, srcStageMask, dstStageMask, 0, 0, nullptr, 0, nullptr, 1, &barrier);

        trackedLayout = newLayout;
        return true;
    }

    SizeT VkTextureManager::CollectGarbage() {
        // Draw-gated stagger (1 in 256 calls): keeps the per-draw cost at one counter
        // bump. The guaranteed reclaim path is the frame-boundary prune in BeginFrame;
        // this remains as a cheap assist so draw-heavy workloads reclaim sooner.
        m_gcCounter++;
        if (m_gcCounter != 0) {
            return 0;
        }
        return PruneDeadTextures();
    }

    SizeT VkTextureManager::PruneDeadTextures() {
        // Erasing entries would dangle the raw TextureResource pointers memoized for the
        // current draw; every call path (BeginFrame, and CollectGarbage at the top of a
        // freshly opened draw-sync scope) runs before any memo entry is recorded.
        MOBILEGL_ASSERT(m_drawSyncedThisDraw.empty(),
                        "PruneDeadTextures: draw-sync memo holds raw resource pointers an erase would dangle");

        Vector<MG_State::GLState::ITextureObject*> expiredTextures;
        expiredTextures.reserve(m_aliveObjects.size());
        for (auto it = m_aliveObjects.begin(); it != m_aliveObjects.end(); ++it) {
            if (it->second.expired()) {
                expiredTextures.emplace_back(it->first.texture);
            }
        }
        for (auto* texture : expiredTextures) {
            PruneStaleTextureAliases(texture);
        }
        SizeT prunedCount = expiredTextures.size();

        // Orphan sweep: after the pass above, m_aliveObjects holds only live entries.
        // Registration in SyncTextureAndGetDescriptor cannot fail for a SharedPtr-owned
        // texture (weak_from_this fallback), so a resource whose identity has no alive
        // entry has no trackable owner: its GL-side object is gone, or was never
        // shared-owned, in which case recreation on a later sync is the safe fallback.
        // Destruction goes through the per-frame deferred queues, never immediate.
        Vector<TextureIdentity> orphanIdentities;
        for (auto it = m_textureResources.begin(); it != m_textureResources.end(); ++it) {
            if (m_aliveObjects.find(it->first) == m_aliveObjects.end()) {
                orphanIdentities.emplace_back(it->first);
            }
        }
        for (const auto& identity : orphanIdentities) {
            EraseTrackedTexture(identity);
        }
        prunedCount += orphanIdentities.size();
        return prunedCount;
    }

    Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture,
                                       TextureResource &outResource) {
        // Cross-draw fast path: if the resource is already built and neither the texture's
        // pixel content (bumped in MarkStorageDirty), its SHAPE (bumped in BumpShapeVersion)
        // nor its params changed since the last sync, there is nothing to re-check or
        // re-upload - skip CheckMipmapCompleteness, SyncTextureResource, SyncTextureViews and
        // the per-level dirty scan. Layout is maintained separately by the transition path, so
        // the resource still reflects truth. The shape version is NOT redundant with the
        // content one: glTexImage2D(..., nullptr) re-specifies a level's size or format
        // without dirtying a texel, which is exactly how a re-specified image-unit texture used
        // to keep reporting its old imageSize().
        const Uint64 syncingContentVersion = texture.GetContentVersion();
        const Uint64 syncingShapeVersion = texture.GetShapeVersion();
        const auto* syncingMipTexture = MG_State::GLState::AsMipmapTexture(&texture);
        const Uint32 syncingMipLevelCount =
            syncingMipTexture != nullptr ? syncingMipTexture->GetMipmapLevelCount() : 0u;
        // A pending storage-usage upgrade also has to bust the skip: nothing about the texture's
        // content or params changed, but the image itself must be recreated with STORAGE usage
        // before it can back an image-unit descriptor.
        const Bool storageUpgradePending =
            !outResource.storageUsageResolved &&
            m_storageImageTextures.find(MakeTextureIdentity(&texture)) != m_storageImageTextures.end();
        // Same shape for a GL texture view's demands on the image (MUTABLE_FORMAT for a
        // format-reinterpreting view, CUBE_COMPATIBLE for a cube view of an array texture):
        // nothing about the texture itself changed, but the live image cannot carry the view.
        // Masked by what this format can actually be given: MUTABLE_FORMAT is deliberately
        // withheld from formats the driver already refused it for (see SyncTextureResource), and
        // without this mask the "upgrade still pending" test below could never come true again -
        // costing every later sync of that texture the whole slow path, forever.
        VkImageCreateFlags requestedViewFlags = GetViewRequestedImageFlags(texture);
        if (m_mutableFormatUnsupported.find(outResource.format) != m_mutableFormatUnsupported.end()) {
            requestedViewFlags &= ~VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
        }
        const Bool viewFlagUpgradePending =
            (outResource.imageCreateFlags & requestedViewFlags) != requestedViewFlags;
        if (outResource.image != VK_NULL_HANDLE && !storageUpgradePending && !viewFlagUpgradePending &&
            outResource.syncedContentVersion == syncingContentVersion &&
            outResource.syncedShapeVersion == syncingShapeVersion &&
            outResource.syncedTextureParamsVersion == texture.GetTextureParamsVersion() &&
            outResource.syncedMipLevelCount == syncingMipLevelCount) {
            return true;
        }

        TextureUploadTarget uploadTarget = TextureUploadTarget::Unknown;
        IntVec3 texelSize{0, 0, 0};
        SizeT byteSize = 0;
        Uint32 mipLevelCount = 0;
        if (!CheckMipmapCompleteness(texture, uploadTarget, texelSize, byteSize, mipLevelCount)) {
            MGLOG_D("%s: mipmap not complete", __func__);
            return false;
        }

        auto* mipTexture = MG_State::GLState::AsMipmapTexture(&texture);
        if (!mipTexture) {
            MGLOG_D("%s: not TextureObjectMipmap", __func__);
            return false;
        }

        // From here down the size is VULKAN geometry, not GL's: a 1D array's layer count moves
        // out of the height it occupies GL-side and into z, which is the slot
        // TryResolveTextureShapeInfo reads arrayLayers from and the only one that leaves
        // extent.height at the 1 a VK_IMAGE_TYPE_1D image is required to have.
        texelSize = ToVulkanLevelExtent(texture.GetTarget(), texelSize);

        if (!SyncTextureResource(texture, uploadTarget, texelSize, byteSize, mipLevelCount, outResource)) {
            MGLOG_D("%s: SyncTextureResource failed", __func__);
            return false;
        }
        if (!SyncTextureViews(texture, outResource)) {
            MGLOG_D("%s: SyncTextureViews failed", __func__);
            return false;
        }

        Vector<TextureUploadTarget> dirtyTargets;
        if (outResource.viewType == VK_IMAGE_VIEW_TYPE_CUBE) {
            dirtyTargets = mipTexture->GetUploadTargets();
        } else {
            dirtyTargets.push_back(uploadTarget);
        }
        Bool hasDirtyMipLevel = false;
        for (const TextureUploadTarget target : dirtyTargets) {
            const Uint32 targetMipLevelCount = std::min(mipLevelCount, GetUploadMipLevelCount(*mipTexture, target));
            for (Uint32 level = 0; level < targetMipLevelCount; ++level) {
                if (mipTexture->IsStorageDirty(target, level)) {
                    hasDirtyMipLevel = true;
                    break;
                }
            }
            if (hasDirtyMipLevel) {
                break;
            }
        }
        if (!hasDirtyMipLevel) {
            outResource.syncedContentVersion = syncingContentVersion;
            outResource.syncedMipLevelCount = syncingMipLevelCount;
            outResource.syncedShapeVersion = syncingShapeVersion;
            return true;
        }

        if (!UploadDirtyMipLevels(*mipTexture, uploadTarget, outResource)) {
            MGLOG_D("%s: UploadDirtyMipLevels failed", __func__);
            return false;
        }
        outResource.syncedContentVersion = syncingContentVersion;
        outResource.syncedMipLevelCount = syncingMipLevelCount;
        outResource.syncedShapeVersion = syncingShapeVersion;
        return true;
    }

    Bool VkTextureManager::SyncTextureResource(const MG_State::GLState::ITextureObject &texture,
                                               TextureUploadTarget uploadTarget,
                                               const IntVec3 &texelSize, SizeT byteSize, Uint32 mipLevels,
                                               TextureResource &resource) {
        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
        VkFormat format = formatInfo.format;
        if (format == VK_FORMAT_UNDEFINED) {
            MGLOG_D("%s: format == VK_FORMAT_UNDEFINED", __func__);
            return false;
        }
        // X8_D24 lacks optimal-tiling support on several drivers (lavapipe included);
        // D32_SFLOAT holds every 24-bit depth value exactly, and the upload path
        // converts the shadow words to float (see the pure-depth branch below).
        if (format == VK_FORMAT_X8_D24_UNORM_PACK32) {
            VkFormatProperties formatProperties{};
            vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
            constexpr VkFormatFeatureFlags kDepthAttachmentAndSample =
                VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT;
            if ((formatProperties.optimalTilingFeatures & kDepthAttachmentAndSample) != kDepthAttachmentAndSample) {
                format = VK_FORMAT_D32_SFLOAT;
            }
        }
        if (texelSize.x() <= 0 || texelSize.y() <= 0 /*|| byteSize == 0*/) {
            MGLOG_D("%s: texelSize or byteSize is zero", __func__);
            return false;
        }
        if (mipLevels == 0) {
            MGLOG_D("%s: no mip levels", __func__);
            return false;
        }
        const Bool isMultisampleTexture = IsMultisampleTextureUploadTarget(uploadTarget);
        // A texture that has only ever defined level 0 gets a single-level backing
        // (ANGLE's model). Preallocating the full chain put every render target
        // onto Adreno's multi-mip image layout and grew each texture by a third
        // for levels most textures never define. Once a second level is defined
        // the backing is recreated ONE time with the full chain (the
        // preserve-copy path below carries the pixels over), so sequentially-
        // defined atlas mips do not recreate per level, and glGenerateMipmap -
        // which defines every level before syncing - works unchanged.
        TextureShapeInfo shapeInfo{};
        const Bool supportedShape = TryResolveTextureShapeInfo(texture, uploadTarget, texelSize, shapeInfo);
        // ComputeFullMipLevelCount takes max(x, y, z), and for every ARRAY shape z is the layer
        // count, not a mip-able axis: a 4x4 array with 192 layers asked for 6 levels on an image
        // whose legal maximum is 3 (VUID-VkImageCreateInfo-mipLevels-00958). Only the image's own
        // extent - width, height and shapeInfo.depth, which is 1 for every array - can bound it.
        // lavapipe has been letting this through unvalidated; a strict driver would not.
        const IntVec3 mipExtent{texelSize.x(), texelSize.y(), static_cast<Int>(shapeInfo.depth)};
        const Uint32 fullMipLevels = ComputeFullMipLevelCount(mipExtent);
        const Uint32 backingMipLevels =
            isMultisampleTexture ? 1u : (mipLevels > 1 ? std::min(std::max(mipLevels, fullMipLevels), fullMipLevels) : 1u);
        if (!supportedShape) {
            // A gap in this backend's coverage, not a broken invariant: the GL front end accepts
            // targets this manager has no Vulkan image shape for yet (cube map arrays above all).
            // Declining the sync leaves the texture unbacked - wrong, but recoverable - where an
            // assertion would take the whole process down instead.
            MGLOG_W_ONCE("SyncTextureResource: unsupported uploadTarget=%s textureTarget=%s textureId=%d size=(%d,%d,%d) "
                    "mipLevels=%u vkViewType=%d",
                    MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str(),
                    MG_Util::ConvertTextureTargetToString(texture.GetTarget()).c_str(), texture.GetExternalIndex(),
                    texelSize.x(), texelSize.y(), texelSize.z(), mipLevels,
                    static_cast<Int>(MG_Util::ConvertTextureUploadTargetToVkEnum(uploadTarget)));
            return false;
        }
        VkSampleCountFlagBits resolvedSampleCount = VK_SAMPLE_COUNT_1_BIT;
        if (isMultisampleTexture &&
            !TryResolveSampleCountFlagBits(texture.GetSamples(), resolvedSampleCount)) {
            MGLOG_D("%s: unsupported multisample count=%d for textureId=%d target=%s", __func__,
                    texture.GetSamples(), texture.GetExternalIndex(),
                    MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str());
            return false;
        }
        // glTexStorage*Multisample(samples = 1) is legal GL, but a one-sample image cannot back a
        // sampler2DMS: VUID-RuntimeSpirv-samples-08726 forbids an OpTypeImage with MS = 1 from
        // reading an image created with VK_SAMPLE_COUNT_1_BIT, and the fetch returns undefined data
        // rather than an error. GL only promises "at least the requested number of samples", so
        // giving a multisample texture two is both legal and the only way to keep the shader's view
        // of it honest. GL_TEXTURE_SAMPLES still reports what the application asked for - that is
        // read off the texture object, not off the image.
        if (isMultisampleTexture && resolvedSampleCount == VK_SAMPLE_COUNT_1_BIT) {
            resolvedSampleCount = VK_SAMPLE_COUNT_2_BIT;
        }

        const VkImageAspectFlags aspect = GetAspectMaskForFormat(format);
        VkFormatProperties formatProperties{};
        vkGetPhysicalDeviceFormatProperties(m_physicalDevice, format, &formatProperties);
        // Only textures that have actually been bound to a GL image unit get STORAGE usage (and
        // the MUTABLE_FORMAT it drags in for format-reinterpreting image views). Requesting it
        // for every storage-capable colour texture costs real bandwidth: Adreno cannot keep UBWC
        // compression on an image that may be written through a storage descriptor, so the whole
        // render target - MC's included - runs uncompressed. MarkStorageImageTexture upgrades a
        // texture before its first image-unit draw, and the usage below feeds the compatibility
        // check so the upgrade recreates the image.
        const Bool markedAsStorageImage =
            m_storageImageTextures.find(MakeTextureIdentity(
                const_cast<MG_State::GLState::ITextureObject*>(&texture))) != m_storageImageTextures.end();
        // Storage-image CAPABILITY (does the format allow it at all) is deliberately separate from
        // whether this texture actually needs the usage. MUTABLE_FORMAT keys off capability, as
        // before: format-reinterpreting views are not a storage-only concern - the SAMPLED path
        // needs them too (GetOrCreateSampledImageView bails out without it, see ~line 892), so
        // tying MUTABLE_FORMAT to the image-unit mark would break sampled format reinterpretation
        // for every texture that never becomes a storage image.
        const Bool storageImageCapable =
            !isMultisampleTexture &&
            (aspect & VK_IMAGE_ASPECT_COLOR_BIT) != 0 &&
            (formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT) != 0;
        const Bool supportsStorageImage = storageImageCapable && markedAsStorageImage;
        VkImageCreateFlags imageCreateFlags = shapeInfo.imageFlags;
        // One z slice of a 3D texture can only be attached to a framebuffer through a 2D view over
        // it, which needs the image to be 2D-array-compatible (Vulkan 1.1 core, promoted from
        // VK_KHR_maintenance1). Asked for optimistically and withdrawn per format below if the
        // driver refuses - losing it only costs per-slice attachment, while failing creation would
        // lose the texture entirely.
        if (shapeInfo.imageType == VK_IMAGE_TYPE_3D && !isMultisampleTexture &&
            m_2dArrayCompatibleUnsupported.find(format) == m_2dArrayCompatibleUnsupported.end()) {
            imageCreateFlags |= VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT;
        }
        if (storageImageCapable && IsMutableStorageImageFormat(format) &&
            m_mutableFormatUnsupported.find(format) == m_mutableFormatUnsupported.end()) {
            imageCreateFlags |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
        }
        // Flags a GL texture view over this storage asked for (see NoteTextureViewImageRequirements).
        // MUTABLE_FORMAT is still withheld from formats the driver has already refused it for, so a
        // reinterpreting view degrades to no view rather than to no texture.
        const VkImageCreateFlags requestedViewFlags = GetViewRequestedImageFlags(texture);
        if (requestedViewFlags != 0) {
            imageCreateFlags |= requestedViewFlags;
            if (m_mutableFormatUnsupported.find(format) != m_mutableFormatUnsupported.end()) {
                imageCreateFlags &= ~VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
            }
        }
        // sRGB color images attach through their UNORM twin while GL_FRAMEBUFFER_SRGB is
        // disabled (see ResolveSrgbAttachmentWriteFormat), which needs format-reinterpreting
        // views - multisample sRGB render targets included.
        if (ResolveSrgbAttachmentWriteFormat(format, false) != format &&
            (aspect & VK_IMAGE_ASPECT_COLOR_BIT) != 0 &&
            m_mutableFormatUnsupported.find(format) == m_mutableFormatUnsupported.end()) {
            imageCreateFlags |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
        }

        VkImageUsageFlags desiredUsage =
            VK_IMAGE_USAGE_SAMPLED_BIT |
            (supportsStorageImage ? VK_IMAGE_USAGE_STORAGE_BIT : 0) |
            ((aspect & VK_IMAGE_ASPECT_COLOR_BIT) ? VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT : 0) |
            (((aspect & VK_IMAGE_ASPECT_DEPTH_BIT) || (aspect & VK_IMAGE_ASPECT_STENCIL_BIT)) ?
                 VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT :
                 0);
        if (!isMultisampleTexture) {
            desiredUsage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
        }

        // Round a multisample request up to a count the device supports for this
        // format (GL only promises "at least"), mirroring the renderbuffer path.
        if (isMultisampleTexture && resolvedSampleCount != VK_SAMPLE_COUNT_1_BIT) {
            auto supportedIt = m_multisampleCountsByFormat.find(format);
            if (supportedIt == m_multisampleCountsByFormat.end()) {
                VkImageFormatProperties imageFormatProperties{};
                VkSampleCountFlags supported = VK_SAMPLE_COUNT_1_BIT;
                if (vkGetPhysicalDeviceImageFormatProperties(m_physicalDevice, format, shapeInfo.imageType,
                                                             VK_IMAGE_TILING_OPTIMAL, desiredUsage, imageCreateFlags,
                                                             &imageFormatProperties) == VK_SUCCESS) {
                    supported = imageFormatProperties.sampleCounts;
                }
                supportedIt = m_multisampleCountsByFormat.emplace(format, supported).first;
            }
            const VkSampleCountFlags supported = supportedIt->second;
            if ((supported & resolvedSampleCount) == 0) {
                Uint32 rounded = 0;
                for (Uint32 bit = static_cast<Uint32>(resolvedSampleCount) << 1; bit <= VK_SAMPLE_COUNT_64_BIT;
                     bit <<= 1) {
                    if ((supported & bit) != 0) {
                        rounded = bit;
                        break;
                    }
                }
                if (rounded == 0) {
                    // Never land on one sample: that is the VUID-RuntimeSpirv-samples-08726
                    // violation the floor above exists to avoid, and it would come back silently
                    // for any format whose only supported count is 1.
                    for (Uint32 bit = static_cast<Uint32>(resolvedSampleCount) >> 1;
                         bit > static_cast<Uint32>(VK_SAMPLE_COUNT_1_BIT); bit >>= 1) {
                        if ((supported & bit) != 0) {
                            rounded = bit;
                            break;
                        }
                    }
                }
                if (rounded == 0 && (supported & VK_SAMPLE_COUNT_1_BIT) != 0) {
                    // Nothing at two samples or above. Reachable because the frontend validates
                    // multisample allocations against the count MobileGL ADVERTISES (GL requires
                    // GL_MAX_SAMPLES >= 4) rather than against the device's per-format support, so
                    // a format this device cannot multisample at all now gets here instead of
                    // being refused up front. Keeping the unsupported count would hand
                    // vkCreateImage an invalid VkImageCreateInfo; one sample is at least a legal
                    // image, and the samples-08726 hazard above is the lesser of the two.
                    MGLOG_W_ONCE("Multisample texture format %d supports no count above one on this device; "
                                 "backing it with a single sample",
                                 static_cast<Int>(format));
                    rounded = static_cast<Uint32>(VK_SAMPLE_COUNT_1_BIT);
                }
                if (rounded != 0) {
                    resolvedSampleCount = static_cast<VkSampleCountFlagBits>(rounded);
                }
            }
        }

        const Bool compatible = resource.image != VK_NULL_HANDLE && resource.format == format &&
                                resource.extent.width == static_cast<Uint32>(texelSize.x()) &&
                                resource.extent.height == static_cast<Uint32>(texelSize.y()) &&
                                resource.depth == shapeInfo.depth &&
                                resource.arrayLayers == shapeInfo.arrayLayers &&
                                resource.viewType == shapeInfo.viewType &&
                                resource.sampleCount == resolvedSampleCount &&
                                resource.imageCreateFlags == imageCreateFlags &&
                                resource.usageFlags == desiredUsage &&
                                resource.mipLevels == backingMipLevels;
        if (compatible) {
            if (resource.perMipViews.size() != backingMipLevels) {
                resource.perMipViews.resize(backingMipLevels, VK_NULL_HANDLE);
            }
            if (resource.perMipSampledViews.size() != backingMipLevels) {
                resource.perMipSampledViews.resize(backingMipLevels, VK_NULL_HANDLE);
            }
            // Keeping the image is itself the answer to the mark: either it already carries
            // STORAGE, or this format can never carry it. Either way there is nothing left to
            // recreate, so stop reporting the texture as needing preparation.
            resource.storageUsageResolved = markedAsStorageImage;
            return true;
        }

        const Bool preserveExistingContent =
            resource.image != VK_NULL_HANDLE &&
            resource.format == format &&
            resource.extent.width == static_cast<Uint32>(texelSize.x()) &&
            resource.extent.height == static_cast<Uint32>(texelSize.y()) &&
            resource.depth == shapeInfo.depth &&
            resource.arrayLayers == shapeInfo.arrayLayers &&
            resource.viewType == shapeInfo.viewType &&
            resource.sampleCount == resolvedSampleCount &&
            resource.imageCreateFlags == imageCreateFlags &&
            resolvedSampleCount == VK_SAMPLE_COUNT_1_BIT &&
            // '<=' rather than '<': a storage-usage upgrade recreates the image with an
            // unchanged mip count, and its contents (a render target's pixels live only on the
            // GPU) still have to survive. The vkCmdCopyImage below copies min(mipLevels).
            resource.mipLevels <= backingMipLevels &&
            resource.layout != VK_IMAGE_LAYOUT_UNDEFINED;

        std::unique_ptr<TextureResource> preservedResource;
        if (preserveExistingContent) {
            preservedResource = std::make_unique<TextureResource>(Move(resource));
        } else {
            DeferResourceRelease(Move(resource));
        }

        VkImageCreateInfo imageInfo{};
        imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
        imageInfo.flags = imageCreateFlags;
        imageInfo.imageType = shapeInfo.imageType;
        imageInfo.extent.width = static_cast<Uint32>(texelSize.x());
        imageInfo.extent.height = static_cast<Uint32>(texelSize.y());
        imageInfo.extent.depth = shapeInfo.depth;
        imageInfo.mipLevels = backingMipLevels;
        imageInfo.arrayLayers = shapeInfo.arrayLayers;
        imageInfo.format = format;
        imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
        imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        imageInfo.usage = desiredUsage;
        imageInfo.samples = resolvedSampleCount;

        // Bound the mutability. A blindly-mutable image has to be laid out so that ANY format in
        // its compatibility class can be viewed, which costs bandwidth compression on tilers;
        // naming the exact set instead lets the driver keep it. Only safe when that set really is
        // exhaustive, so it is restricted to textures that are not image-unit bound: sampled views
        // can only ever ask for ResolveSampledImageViewFormat's output, whereas glBindImageTexture
        // may name any compatible format, which nothing here can enumerate ahead of time.
        Vector<VkFormat> viewFormats;
        VkImageFormatListCreateInfo formatListInfo{};
        if (m_imageFormatListSupported && !supportsStorageImage &&
            (imageInfo.flags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) != 0) {
            viewFormats.push_back(format);
            for (const SamplerNumericDomain domain : {SamplerNumericDomain::Float,
                                                      SamplerNumericDomain::SignedInteger,
                                                      SamplerNumericDomain::UnsignedInteger}) {
                const VkFormat viewFormat = ResolveSampledImageViewFormat(format, domain);
                if (viewFormat == VK_FORMAT_UNDEFINED) {
                    continue;
                }
                if (std::find(viewFormats.begin(), viewFormats.end(), viewFormat) == viewFormats.end()) {
                    viewFormats.push_back(viewFormat);
                }
            }
            // ...plus every format a glTextureView over this storage reinterprets it as. Those
            // are NOT enumerable from ResolveSampledImageViewFormat - an application may name any
            // member of the format's view class (GL 4.6 core table 8.21) - so without this the
            // list would forbid the very view the MUTABLE_FORMAT bit was requested for.
            AppendViewRequestedFormats(texture, viewFormats);
            formatListInfo.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO;
            formatListInfo.viewFormatCount = static_cast<Uint32>(viewFormats.size());
            formatListInfo.pViewFormats = viewFormats.data();
            imageInfo.pNext = &formatListInfo;
        }

        if (isMultisampleTexture || (imageInfo.flags & (VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT |
                                                        VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT)) != 0) {
            VkImageFormatProperties imageFormatProperties{};
            VkResult imageFormatResult = vkGetPhysicalDeviceImageFormatProperties(
                m_physicalDevice, format, imageInfo.imageType, imageInfo.tiling, imageInfo.usage,
                imageInfo.flags, &imageFormatProperties);
            if (imageFormatResult != VK_SUCCESS && !isMultisampleTexture &&
                (imageInfo.flags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) != 0) {
                // Losing reinterpreted views only degrades the formatless-image feature for
                // this texture; failing creation would lose the texture entirely, so retry
                // as a plain immutable-format image.
                MGLOG_W_ONCE("%s: mutable image format=%d is unsupported for textureId=%d; creating "
                        "without VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT (format reinterpretation "
                        "will be unavailable for it)",
                        __func__, static_cast<Int>(format), texture.GetExternalIndex());
                // Remember the verdict so later syncs of same-format textures neither retry
                // the probe nor flag-mismatch against this image and recreate it.
                m_mutableFormatUnsupported.insert(format);
                imageInfo.flags &= ~VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
                imageCreateFlags = imageInfo.flags;
                imageFormatResult = vkGetPhysicalDeviceImageFormatProperties(
                    m_physicalDevice, format, imageInfo.imageType, imageInfo.tiling, imageInfo.usage,
                    imageInfo.flags, &imageFormatProperties);
            }
            if (imageFormatResult != VK_SUCCESS && !isMultisampleTexture &&
                (imageInfo.flags & VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT) != 0) {
                // Losing 2D-array compatibility only costs framebuffer attachment of this format's
                // 3D images - per-slice AND layered, since both are spelled as a 2D-family view over
                // the z axis; failing creation would lose the texture entirely. Recorded here (the
                // per-format set below) so later syncs neither reprobe nor flag-mismatch against this
                // image and recreate it, and so GetOrCreateAttachmentViewAtMipLevel declines rather
                // than handing back a view that cannot exist - the render-pass builder then turns
                // that decline into a skipped draw instead of a null VkImageView in pAttachments.
                MGLOG_W_ONCE("%s: VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT is unsupported for format=%d "
                        "textureId=%d; creating without it (per-slice and layered framebuffer "
                        "attachment of 3D textures in this format will be unavailable)",
                        __func__, static_cast<Int>(format), texture.GetExternalIndex());
                m_2dArrayCompatibleUnsupported.insert(format);
                imageInfo.flags &= ~VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT;
                imageCreateFlags = imageInfo.flags;
                imageFormatResult = vkGetPhysicalDeviceImageFormatProperties(
                    m_physicalDevice, format, imageInfo.imageType, imageInfo.tiling, imageInfo.usage,
                    imageInfo.flags, &imageFormatProperties);
            }
            if (imageFormatResult != VK_SUCCESS ||
                (isMultisampleTexture && (imageFormatProperties.sampleCounts & resolvedSampleCount) == 0)) {
                MGLOG_D("%s: image flags=0x%x sampleCount=%d are unsupported for textureId=%d target=%s "
                        "format=%d usage=0x%x",
                        __func__, static_cast<Uint32>(imageInfo.flags), texture.GetSamples(),
                        texture.GetExternalIndex(),
                        MG_Util::ConvertTextureUploadTargetToString(uploadTarget).c_str(),
                        static_cast<Int>(format), static_cast<Uint32>(imageInfo.usage));
                // The preserved image was written by GPU work that may still be in flight
                // (preserve requires layout != UNDEFINED); park it on the deferred ring
                // like every other destruction path instead of letting the unique_ptr
                // destroy it synchronously under the GPU.
                if (preservedResource) {
                    DeferResourceRelease(Move(*preservedResource));
                }
                return false;
            }
        }
        imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
        VmaAllocationCreateInfo allocationInfo{};
        allocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE;
        allocationInfo.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
        // Soft failure like the unsupported-sample-count path above: a driver can pass the
        // vkGetPhysicalDeviceImageFormatProperties pre-check yet still refuse the creation
        // (e.g. multisampled depth on lavapipe); the texture simply stays unbacked.
        const VkResult createImageResult =
            vmaCreateImage(m_allocator, &imageInfo, &allocationInfo, &resource.image, &resource.allocation, nullptr);
        if (createImageResult != VK_SUCCESS) {
            // E_ONCE, not F: the comment above says it - this is a soft failure the caller
            // recovers from, and it re-fires on every sync of every texture the driver refuses.
            MGLOG_E_ONCE("SyncTextureResource: vmaCreateImage failed (%d) textureId=%d extent=%ux%u depth=%u layers=%u "
                    "mips=%u samples=%d format=%d",
                    createImageResult, texture.GetExternalIndex(), imageInfo.extent.width, imageInfo.extent.height,
                    imageInfo.extent.depth, imageInfo.arrayLayers, imageInfo.mipLevels,
                    static_cast<Int>(imageInfo.samples), static_cast<Int>(imageInfo.format));
            resource.image = VK_NULL_HANDLE;
            resource.allocation = nullptr;
            // Same as the probe failure above: the preserved live image must go through
            // the deferred ring, never a synchronous destructor while frames that
            // reference it are still in flight.
            if (preservedResource) {
                DeferResourceRelease(Move(*preservedResource));
            }
            return false;
        }
        ++m_textureImageEpoch; // a new attachment image invalidates cached render passes

        resource.layout = VK_IMAGE_LAYOUT_UNDEFINED;
        resource.extent = {static_cast<Uint32>(texelSize.x()), static_cast<Uint32>(texelSize.y())};
        resource.depth = shapeInfo.depth;
        resource.arrayLayers = shapeInfo.arrayLayers;
        resource.mipLevels = backingMipLevels;
        resource.perMipViews.assign(backingMipLevels, VK_NULL_HANDLE);
        resource.perMipSampledViews.assign(backingMipLevels, VK_NULL_HANDLE);
        resource.sampledBaseMipLevel = 0;
        resource.sampledLevelCount = mipLevels;
        resource.format = format;
        resource.aspect = aspect;
        resource.viewType = shapeInfo.viewType;
        resource.sampleCount = resolvedSampleCount;
        resource.imageCreateFlags = imageCreateFlags;
        resource.usageFlags = imageInfo.usage;
        resource.storageUsageResolved = markedAsStorageImage;
        resource.syncedTextureParamsVersion = 0;

        if (preservedResource) {
            // The preserve copy reads the OLD image on its own immediately-
            // submitted-and-waited command buffer; a batched upload into that
            // image still sitting in the open batch must reach the queue first
            // or the copy carries pre-upload texels forward.
            FlushPendingUploads();
            const Bool preserved = PreserveTextureContentsOnRecreate(
                m_device, m_commandPool, m_graphicsQueue, *preservedResource, resource);
            MOBILEGL_ASSERT(preserved,
                            "SyncTextureResource: failed to preserve texture contents while growing mip chain");
            DeferResourceRelease(Move(*preservedResource));
        }
        return true;
    }

    void VkTextureManager::DeferResourceRelease(TextureResource&& resource) {
        // The deferred-release queues are drained under fence/queue-idle proofs
        // that only cover SUBMITTED work; a recorded-but-unsubmitted upload
        // batch referencing this image would escape them. Push the batch onto
        // the queue first so every later proof covers it. Rare (only recreate/
        // erase of an image uploaded this very frame), so the flush is cheap.
        if (m_uploadBatchOpen && resource.image != VK_NULL_HANDLE &&
            std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), resource.image) !=
                m_uploadBatchImages.end()) {
            FlushPendingUploads();
        }
        if (resource.image == VK_NULL_HANDLE && resource.fullView == VK_NULL_HANDLE &&
            resource.sampledView == VK_NULL_HANDLE &&
            resource.perMipViews.empty() && resource.perMipSampledViews.empty() &&
            resource.attachmentViews.empty() && resource.alternateSampledViews.empty() &&
            resource.storageImageViews.empty()) {
            return;
        }

        if (m_deferredReleases.empty()) {
            resource.Reset();
            return;
        }

        MOBILEGL_ASSERT(m_currentFrameIndex < m_deferredReleases.size(),
                        "VkTextureManager::DeferResourceRelease invalid current frame index %u (size=%zu)",
                        m_currentFrameIndex, m_deferredReleases.size());
        m_deferredReleases[m_currentFrameIndex].push_back(Move(resource));
    }

    void VkTextureManager::CollectDeferredReleases(Uint32 frameIndex) {
        MOBILEGL_ASSERT(frameIndex < m_deferredReleases.size(),
                        "VkTextureManager::CollectDeferredReleases invalid frame index %u (size=%zu)",
                        frameIndex, m_deferredReleases.size());
        m_deferredReleases[frameIndex].clear();

        MOBILEGL_ASSERT(frameIndex < m_deferredViewReleases.size(),
                        "VkTextureManager::CollectDeferredReleases invalid deferred-view frame index %u (size=%zu)",
                        frameIndex, m_deferredViewReleases.size());
        for (const VkImageView view : m_deferredViewReleases[frameIndex]) {
            if (view != VK_NULL_HANDLE) {
                vkDestroyImageView(m_device, view, nullptr);
            }
        }
        m_deferredViewReleases[frameIndex].clear();
    }

    void VkTextureManager::ReclaimCompletedUploads(Bool waitAll) {
        if (m_pendingUploadReclaims.empty()) {
            return;
        }

        SizeT completed = 0;
        for (; completed < m_pendingUploadReclaims.size(); ++completed) {
            PendingUploadReclaim& entry = m_pendingUploadReclaims[completed];
            if (waitAll) {
                VK_VERIFY(vkWaitForFences(m_device, 1, &entry.fence, VK_TRUE, UINT64_MAX),
                          "vkWaitForFences(texture upload reclaim)");
            } else if (vkGetFenceStatus(m_device, entry.fence) != VK_SUCCESS) {
                break;
            }
            // Recycle, don't destroy: the fence resets into the fence pool,
            // the command buffer resets into the CB pool (m_uploadCommandPool
            // carries RESET_COMMAND_BUFFER_BIT), and the staging blocks
            // return to the block pool for the next batch to bump-allocate.
            // This is where the mc_tex_stream win comes from: the per-upload
            // fence create/destroy + command-buffer alloc/free ioctl traffic
            // was the measured 41%-in-kernel cost, not the submit itself.
            if (vkResetFences(m_device, 1, &entry.fence) == VK_SUCCESS) {
                m_freeUploadFences.push_back(entry.fence);
            } else {
                vkDestroyFence(m_device, entry.fence, nullptr);
            }
            if (vkResetCommandBuffer(entry.commandBuffer, 0) == VK_SUCCESS) {
                m_freeUploadCommandBuffers.push_back(entry.commandBuffer);
            } else {
                vkFreeCommandBuffers(m_device, m_uploadCommandPool, 1, &entry.commandBuffer);
            }
            for (auto& block : entry.stagingBlocks) {
                RecycleUploadStagingBlock(Move(block));
            }
            entry.stagingBlocks.clear();
        }
        m_pendingUploadReclaims.erase(m_pendingUploadReclaims.begin(),
                                      m_pendingUploadReclaims.begin() + static_cast<std::ptrdiff_t>(completed));
    }

    void VkTextureManager::RecycleUploadStagingBlock(UploadStagingBlock&& block) {
        if (block.buffer == VK_NULL_HANDLE) {
            return;
        }
        // Bound the idle pool: a one-off giant upload (initial atlas define)
        // must not pin its staging memory forever.
        constexpr VkDeviceSize kMaxFreeUploadStagingBytes = 32u * 1024u * 1024u;
        if (m_allocator == nullptr || m_freeUploadStagingBytes + block.capacity > kMaxFreeUploadStagingBytes) {
            vmaDestroyBuffer(m_allocator, block.buffer, block.allocation);
            return;
        }
        block.cursor = 0;
        m_freeUploadStagingBytes += block.capacity;
        m_freeUploadStagingBlocks.push_back(Move(block));
    }

    VkCommandBuffer VkTextureManager::EnsureUploadBatchOpen() {
        if (m_uploadBatchOpen) {
            return m_uploadBatchCommandBuffer;
        }
        if (!m_freeUploadCommandBuffers.empty()) {
            m_uploadBatchCommandBuffer = m_freeUploadCommandBuffers.back();
            m_freeUploadCommandBuffers.pop_back();
        } else {
            VkCommandBufferAllocateInfo allocInfo{};
            allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
            allocInfo.commandPool = m_uploadCommandPool;
            allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
            allocInfo.commandBufferCount = 1;
            VK_VERIFY(vkAllocateCommandBuffers(m_device, &allocInfo, &m_uploadBatchCommandBuffer),
                      "vkAllocateCommandBuffers(texture upload batch)");
        }
        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
        beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
        VK_VERIFY(vkBeginCommandBuffer(m_uploadBatchCommandBuffer, &beginInfo),
                  "vkBeginCommandBuffer(texture upload batch)");
        m_uploadBatchOpen = true;
        return m_uploadBatchCommandBuffer;
    }

    Uint8* VkTextureManager::AcquireUploadStagingSpace(VkDeviceSize size, VkBuffer& outBuffer,
                                                       VkDeviceSize& outBaseOffset) {
        // 16 covers every uncompressed texel size in use (1..16 bytes) and the
        // bufferOffset multiple-of-4 rule; per-item offsets inside the span
        // keep the pre-batching tight packing.
        constexpr VkDeviceSize kUploadStagingAlignment = 16;
        constexpr VkDeviceSize kUploadStagingBlockSize = 1u * 1024u * 1024u;
        UploadStagingBlock* current = m_uploadBatchBlocks.empty() ? nullptr : &m_uploadBatchBlocks.back();
        VkDeviceSize alignedCursor = 0;
        if (current != nullptr) {
            alignedCursor = (current->cursor + (kUploadStagingAlignment - 1)) & ~(kUploadStagingAlignment - 1);
            if (alignedCursor + size > current->capacity) {
                current = nullptr;
            }
        }
        if (current == nullptr) {
            UploadStagingBlock block;
            for (SizeT i = 0; i < m_freeUploadStagingBlocks.size(); ++i) {
                if (m_freeUploadStagingBlocks[i].capacity >= size) {
                    block = Move(m_freeUploadStagingBlocks[i]);
                    m_freeUploadStagingBytes -= block.capacity;
                    m_freeUploadStagingBlocks.erase(m_freeUploadStagingBlocks.begin() +
                                                    static_cast<std::ptrdiff_t>(i));
                    break;
                }
            }
            if (block.buffer == VK_NULL_HANDLE) {
                VkBufferCreateInfo bufferInfo{};
                bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
                bufferInfo.size = std::max(kUploadStagingBlockSize, size);
                bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
                bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
                VmaAllocationCreateInfo stagingAllocationInfo{};
                stagingAllocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST;
                stagingAllocationInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
                                              VMA_ALLOCATION_CREATE_MAPPED_BIT;
                stagingAllocationInfo.requiredFlags =
                    VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
                VmaAllocationInfo allocationResult{};
                VK_VERIFY(vmaCreateBuffer(m_allocator, &bufferInfo, &stagingAllocationInfo, &block.buffer,
                                          &block.allocation, &allocationResult),
                          "vmaCreateBuffer(texture upload staging block)");
                block.mapped = static_cast<Uint8*>(allocationResult.pMappedData);
                block.capacity = bufferInfo.size;
                MOBILEGL_ASSERT(block.mapped != nullptr,
                                "AcquireUploadStagingSpace: staging block is not persistently mapped");
            }
            block.cursor = 0;
            m_uploadBatchBlocks.push_back(Move(block));
            current = &m_uploadBatchBlocks.back();
            alignedCursor = 0;
        }
        outBuffer = current->buffer;
        outBaseOffset = alignedCursor;
        current->cursor = alignedCursor + size;
        return current->mapped + alignedCursor;
    }

    void VkTextureManager::FlushPendingUploads() {
        if (!m_uploadBatchOpen) {
            return;
        }
        VK_VERIFY(vkEndCommandBuffer(m_uploadBatchCommandBuffer), "vkEndCommandBuffer(texture upload batch)");

        VkFence uploadFence = VK_NULL_HANDLE;
        if (!m_freeUploadFences.empty()) {
            uploadFence = m_freeUploadFences.back();
            m_freeUploadFences.pop_back();
        } else {
            VkFenceCreateInfo fenceInfo{};
            fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
            VK_VERIFY(vkCreateFence(m_device, &fenceInfo, nullptr, &uploadFence), "vkCreateFence(texture upload)");
        }

        VkSubmitInfo submitInfo{};
        submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
        submitInfo.commandBufferCount = 1;
        submitInfo.pCommandBuffers = &m_uploadBatchCommandBuffer;
        VK_VERIFY(vkQueueSubmit(m_graphicsQueue, 1, &submitInfo, uploadFence), "vkQueueSubmit(texture upload batch)");

        PendingUploadReclaim reclaim;
        reclaim.fence = uploadFence;
        reclaim.commandBuffer = m_uploadBatchCommandBuffer;
        reclaim.stagingBlocks = Move(m_uploadBatchBlocks);
        m_pendingUploadReclaims.push_back(Move(reclaim));
        m_uploadBatchCommandBuffer = VK_NULL_HANDLE;
        m_uploadBatchOpen = false;
        m_uploadBatchBlocks.clear();
        m_uploadBatchImages.clear();
        m_uploadBatchStagingBytes = 0;

        ReclaimCompletedUploads();
        // Backstop for pathological upload storms: bound in-flight staging
        // memory by blocking on the oldest batch only once the list is deep.
        constexpr SizeT kMaxPendingTextureUploads = 16;
        if (m_pendingUploadReclaims.size() > kMaxPendingTextureUploads) {
            VK_VERIFY(vkWaitForFences(m_device, 1, &m_pendingUploadReclaims.front().fence, VK_TRUE, UINT64_MAX),
                      "vkWaitForFences(texture upload backstop)");
            ReclaimCompletedUploads();
        }
    }

    void VkTextureManager::DiscardPendingUploadBatch() {
        if (!m_uploadBatchOpen) {
            return;
        }
        // The batch was never submitted, so the command buffer is in the
        // recording state, not pending - freeing it is legal.
        vkFreeCommandBuffers(m_device, m_uploadCommandPool, 1, &m_uploadBatchCommandBuffer);
        m_uploadBatchCommandBuffer = VK_NULL_HANDLE;
        m_uploadBatchOpen = false;
        for (auto& block : m_uploadBatchBlocks) {
            RecycleUploadStagingBlock(Move(block));
        }
        m_uploadBatchBlocks.clear();
        m_uploadBatchImages.clear();
        m_uploadBatchStagingBytes = 0;
    }

    void VkTextureManager::DestroyUploadPools() {
        for (auto& block : m_freeUploadStagingBlocks) {
            if (block.buffer != VK_NULL_HANDLE) {
                vmaDestroyBuffer(m_allocator, block.buffer, block.allocation);
            }
        }
        m_freeUploadStagingBlocks.clear();
        m_freeUploadStagingBytes = 0;
        if (!m_freeUploadCommandBuffers.empty()) {
            vkFreeCommandBuffers(m_device, m_uploadCommandPool, static_cast<Uint32>(m_freeUploadCommandBuffers.size()),
                                 m_freeUploadCommandBuffers.data());
            m_freeUploadCommandBuffers.clear();
        }
        for (const VkFence fence : m_freeUploadFences) {
            vkDestroyFence(m_device, fence, nullptr);
        }
        m_freeUploadFences.clear();
    }

    void VkTextureManager::DestroyDeferredReleases() {
        for (auto& deferredReleases : m_deferredReleases) {
            deferredReleases.clear();
        }
        m_deferredReleases.clear();

        for (auto& deferredViews : m_deferredViewReleases) {
            for (const VkImageView view : deferredViews) {
                if (view != VK_NULL_HANDLE) {
                    vkDestroyImageView(m_device, view, nullptr);
                }
            }
            deferredViews.clear();
        }
        m_deferredViewReleases.clear();
    }

    void VkTextureManager::DeferViewRelease(VkImageView view) {
        if (view == VK_NULL_HANDLE) {
            return;
        }

        if (m_deferredViewReleases.empty()) {
            vkDestroyImageView(m_device, view, nullptr);
            return;
        }

        MOBILEGL_ASSERT(m_currentFrameIndex < m_deferredViewReleases.size(),
                        "VkTextureManager::DeferViewRelease invalid current frame index %u (size=%zu)",
                        m_currentFrameIndex, m_deferredViewReleases.size());
        m_deferredViewReleases[m_currentFrameIndex].push_back(view);
    }

    MG_State::GLState::ITextureObject& VkTextureManager::StorageTextureOf(
        MG_State::GLState::ITextureObject& texture) {
        const auto& storageOwner = texture.GetViewStorageOwner();
        return storageOwner ? *storageOwner : texture;
    }

    // The VkImageViewType a GL texture view's own target asks for. Deliberately derived from the
    // GL target rather than inherited from the storage image: a 2D view of a 2D-array texture is
    // a VK_IMAGE_VIEW_TYPE_2D over one layer, and a cube view of the same image is a
    // VK_IMAGE_VIEW_TYPE_CUBE over six - which is the whole reason table 8.20 lists those pairs.
    static VkImageViewType ResolveTextureViewImageViewType(TextureTarget target,
                                                           VkImageViewType storageViewType) {
        switch (target) {
        case TextureTarget::Texture1D:
            return VK_IMAGE_VIEW_TYPE_1D;
        case TextureTarget::Texture1DArray:
            return VK_IMAGE_VIEW_TYPE_1D_ARRAY;
        case TextureTarget::Texture2D:
        case TextureTarget::TextureRectangle:
        case TextureTarget::Texture2DMultisample:
            return VK_IMAGE_VIEW_TYPE_2D;
        case TextureTarget::Texture2DArray:
        case TextureTarget::Texture2DMultisampleArray:
            return VK_IMAGE_VIEW_TYPE_2D_ARRAY;
        case TextureTarget::TextureCubeMap:
            return VK_IMAGE_VIEW_TYPE_CUBE;
        case TextureTarget::TextureCubeMapArray:
            return VK_IMAGE_VIEW_TYPE_CUBE_ARRAY;
        default:
            return storageViewType;
        }
    }

    VkTextureManager::TextureViewWindow VkTextureManager::ResolveTextureViewWindow(
        MG_State::GLState::ITextureObject& texture, const TextureResource& resource) const {
        TextureViewWindow window{};
        window.format = resource.format;
        window.viewType = resource.viewType;
        window.baseArrayLayer = 0;
        window.layerCount = resource.arrayLayers;
        window.sampledAspect =
            ResolveSampledImageViewAspectMask(resource.aspect, texture.GetDepthStencilTextureMode());
        window.components = ResolveSampledViewComponents(texture, ResolveTextureFormatInfo(texture.GetFormat()));
        ResolveViewMipRange(texture, resource.mipLevels, window.baseMipLevel, window.levelCount);
        if (!texture.IsTextureView()) {
            return window;
        }

        window.isTextureView = true;
        // GL 4.6 core 8.18: the view's TEXTURE_BASE_LEVEL / TEXTURE_MAX_LEVEL are relative to the
        // view, so ResolveViewMipRange above already clamped them against the view's own level
        // count (TextureObjectView reports it); shifting by TEXTURE_VIEW_MIN_LEVEL puts them back
        // into the storage image's numbering.
        window.baseMipLevel += static_cast<Uint32>(texture.GetViewMinLevel());
        window.baseArrayLayer = static_cast<Uint32>(texture.GetViewMinLayer());
        window.layerCount = static_cast<Uint32>(texture.GetViewNumLayers());
        window.viewType = ResolveTextureViewImageViewType(texture.GetTarget(), resource.viewType);
        // The view's OWN internalformat, which may reinterpret the storage's (table 8.21).
        const VkFormat viewFormat = ResolveTextureFormatInfo(texture.GetFormat()).format;
        if (viewFormat != VK_FORMAT_UNDEFINED) {
            window.format = viewFormat;
        }
        // Recomputed against the view's own format: a depth/stencil storage viewed as
        // depth/stencil still has to honour the VIEW's DEPTH_STENCIL_TEXTURE_MODE, which is the
        // one parameter Better Clouds deliberately sets differently on the two names.
        window.sampledAspect =
            ResolveSampledImageViewAspectMask(GetAspectMaskForFormat(window.format) != VK_IMAGE_ASPECT_NONE
                                                  ? GetAspectMaskForFormat(window.format)
                                                  : resource.aspect,
                                              texture.GetDepthStencilTextureMode());

        // Clamp to what the image actually has; a malformed view must degrade to an empty range
        // rather than reach vkCreateImageView with an out-of-bounds subresource.
        if (window.baseMipLevel >= resource.mipLevels) {
            window.baseMipLevel = resource.mipLevels - 1;
            window.levelCount = 1;
        } else {
            window.levelCount = std::min(window.levelCount, resource.mipLevels - window.baseMipLevel);
        }
        if (window.levelCount == 0) window.levelCount = 1;
        if (window.baseArrayLayer >= resource.arrayLayers) {
            window.baseArrayLayer = resource.arrayLayers - 1;
            window.layerCount = 1;
        } else {
            window.layerCount = std::min(window.layerCount, resource.arrayLayers - window.baseArrayLayer);
        }
        if (window.layerCount == 0) window.layerCount = 1;
        return window;
    }

    // The extra VkImageCreateFlags a GL texture view needs on the image it views. Recorded
    // BEFORE the storage texture is synced (see SyncTextureAndGetDescriptor) so the very first
    // resolve of a view already creates - or recreates and copies forward - an image the view can
    // legally be built over, instead of handing back VK_NULL_HANDLE for a frame.
    void VkTextureManager::NoteTextureViewImageRequirements(MG_State::GLState::ITextureObject& viewTexture,
                                                            MG_State::GLState::ITextureObject& storageTexture) {
        const TextureIdentity storageIdentity = MakeTextureIdentity(&storageTexture);
        VkImageCreateFlags required = 0;
        const VkFormat viewFormat = ResolveTextureFormatInfo(viewTexture.GetFormat()).format;
        const VkFormat storageFormat = ResolveTextureFormatInfo(storageTexture.GetFormat()).format;
        if (viewFormat != VK_FORMAT_UNDEFINED && storageFormat != VK_FORMAT_UNDEFINED &&
            viewFormat != storageFormat) {
            required |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
            // The image may be created with a NARROWED format list (see SyncTextureResource), and
            // that list is a promise about every format the image will ever be viewed as. Record
            // this one so the promise stays true.
            m_viewRequestedFormats[storageIdentity].insert(viewFormat);
        }
        const TextureTarget viewTarget = viewTexture.GetTarget();
        if (viewTarget == TextureTarget::TextureCubeMap || viewTarget == TextureTarget::TextureCubeMapArray) {
            // Only when the storage could legally carry the bit. VK_IMAGE_CREATE_CUBE_COMPATIBLE
            // demands a 2D image with square levels and at least six array layers
            // (VUID-VkImageCreateInfo-flags-00954), and asking for it on a storage that has fewer
            // would fail vkCreateImage - which, because SyncTextureResource has already released
            // the old resource by then, would leave the PARENT texture with no image at all. A
            // degenerate view must not be able to destroy the texture it views; let its own view
            // creation fail instead.
            const IntVec3 storageSize = storageTexture.GetBaseSize();
            const Bool storageCanBeCube = storageSize.x() == storageSize.y() &&
                                          storageTexture.GetViewNumLayers() >= 6 &&
                                          storageTexture.GetTarget() != TextureTarget::Texture3D;
            if (storageCanBeCube) {
                required |= VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
            } else {
                MGLOG_W_ONCE("Texture view %d wants a cube view of texture %d, whose storage is %dx%d with %u "
                             "layers and cannot be cube-compatible; the view will have no image view.",
                             viewTexture.GetExternalIndex(), storageTexture.GetExternalIndex(), storageSize.x(),
                             storageSize.y(), storageTexture.GetViewNumLayers());
            }
        }
        if (required == 0) {
            return;
        }
        VkImageCreateFlags& stored = m_viewRequestedImageFlags[storageIdentity];
        stored |= required;
    }

    VkImageCreateFlags VkTextureManager::GetViewRequestedImageFlags(
        const MG_State::GLState::ITextureObject& storageTexture) const {
        const auto it = m_viewRequestedImageFlags.find(
            MakeTextureIdentity(const_cast<MG_State::GLState::ITextureObject*>(&storageTexture)));
        return it == m_viewRequestedImageFlags.end() ? 0 : it->second;
    }

    void VkTextureManager::AppendViewRequestedFormats(const MG_State::GLState::ITextureObject& storageTexture,
                                                      Vector<VkFormat>& outFormats) const {
        const auto it = m_viewRequestedFormats.find(
            MakeTextureIdentity(const_cast<MG_State::GLState::ITextureObject*>(&storageTexture)));
        if (it == m_viewRequestedFormats.end()) {
            return;
        }
        for (const VkFormat viewFormat : it->second) {
            if (std::find(outFormats.begin(), outFormats.end(), viewFormat) == outFormats.end()) {
                outFormats.push_back(viewFormat);
            }
        }
    }

    Bool VkTextureManager::SyncTextureViews(const MG_State::GLState::ITextureObject& texture, TextureResource& resource) {
        MOBILEGL_ASSERT(resource.image != VK_NULL_HANDLE, "SyncTextureViews: image == VK_NULL_HANDLE");

        Uint32 baseMipLevel = 0;
        Uint32 levelCount = 1;
        ResolveViewMipRange(texture, resource.mipLevels, baseMipLevel, levelCount);

        const Bool needsRecreate =
            resource.fullView == VK_NULL_HANDLE ||
            resource.sampledView == VK_NULL_HANDLE ||
            resource.sampledBaseMipLevel != baseMipLevel ||
            resource.sampledLevelCount != levelCount ||
            resource.syncedTextureParamsVersion != texture.GetTextureParamsVersion();
        if (!needsRecreate) {
            return true;
        }

        if (resource.fullView != VK_NULL_HANDLE) {
            DeferViewRelease(resource.fullView);
            resource.fullView = VK_NULL_HANDLE;
        }
        if (resource.sampledView != VK_NULL_HANDLE) {
            DeferViewRelease(resource.sampledView);
            resource.sampledView = VK_NULL_HANDLE;
        }
        for (auto& sampledView : resource.perMipSampledViews) {
            if (sampledView != VK_NULL_HANDLE) {
                DeferViewRelease(sampledView);
                sampledView = VK_NULL_HANDLE;
            }
        }
        for (const auto& [_, sampledView] : resource.alternateSampledViews) {
            DeferViewRelease(sampledView);
        }
        resource.alternateSampledViews.clear();

        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(texture.GetFormat());
        const VkComponentMapping sampledComponents = ResolveSampledViewComponents(texture, formatInfo);
        resource.fullView = CreateImageView(resource.image, resource.format, resource.aspect, resource.viewType,
                                            baseMipLevel, levelCount, 0, resource.arrayLayers, &sampledComponents);
        if (resource.fullView == VK_NULL_HANDLE) {
            return false;
        }
        const VkImageAspectFlags sampledAspect =
            ResolveSampledImageViewAspectMask(resource.aspect, texture.GetDepthStencilTextureMode());
        resource.sampledView = CreateImageView(resource.image, resource.format, sampledAspect, resource.viewType,
                                               baseMipLevel, levelCount, 0, resource.arrayLayers, &sampledComponents);
        if (resource.sampledView == VK_NULL_HANDLE) {
            return false;
        }

        resource.sampledBaseMipLevel = baseMipLevel;
        resource.sampledLevelCount = levelCount;
        resource.syncedTextureParamsVersion = texture.GetTextureParamsVersion();
        return true;
    }

    VkImageView VkTextureManager::CreateImageView(VkImage image, VkFormat format, VkImageAspectFlags aspect,
                                                  VkImageViewType viewType, Uint32 baseMipLevel, Uint32 levelCount,
                                                  Uint32 baseArrayLayer,
                                                  Uint32 layerCount,
                                                  const VkComponentMapping* components,
                                                  VkImageUsageFlags viewUsage) const {
        VkImageViewCreateInfo viewInfo{};
        viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
        viewInfo.image = image;
        viewInfo.viewType = viewType;
        viewInfo.format = format;
        viewInfo.components = components != nullptr ?
            *components :
            VkComponentMapping{VK_COMPONENT_SWIZZLE_R, VK_COMPONENT_SWIZZLE_G,
                               VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_A};
        viewInfo.subresourceRange.aspectMask = aspect;
        viewInfo.subresourceRange.baseMipLevel = baseMipLevel;
        viewInfo.subresourceRange.levelCount = levelCount;
        viewInfo.subresourceRange.baseArrayLayer = baseArrayLayer;
        viewInfo.subresourceRange.layerCount = layerCount;

        VkImageViewUsageCreateInfo usageInfo{};
        if (viewUsage != 0) {
            usageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_USAGE_CREATE_INFO;
            usageInfo.usage = viewUsage;
            viewInfo.pNext = &usageInfo;
        }

        VkImageView view = VK_NULL_HANDLE;
        VK_VERIFY(vkCreateImageView(m_device, &viewInfo, nullptr, &view), "vkCreateImageView(texture)");
        return view;
    }

    Bool VkTextureManager::UploadDirtyMipLevels(MG_State::GLState::TextureObjectMipmap &mipmapTexture,
                                        TextureUploadTarget uploadTarget,
                                        TextureResource &outResource) {
        struct UploadItem {
            TextureUploadTarget target = TextureUploadTarget::Unknown;
            Uint32 level = 0;
            Uint32 baseArrayLayer = 0;
            SizeT uploadByteSize = 0;
            IntVec3 texelSize = {0, 0, 0};
            const void* source = nullptr;
            Vector<Uint8> expandedData;
            VkDeviceSize offset = 0;
            // Sub-region upload (a small sprite in a big atlas): only the dirty box
            // is staged and copied. texelSize keeps the LEVEL extent - the staging
            // row copy needs it for the shadow's stride. Plain color formats only;
            // the RGB-expand and depth(+stencil) conversion passes rewrite whole
            // levels and stay full-size.
            Bool subRegion = false;
            IntVec3 regionLo = {0, 0, 0};
            IntVec3 regionSize = {0, 0, 0};
            SizeT texelBytes = 0;
            // Scatter refinement of the single dirty box: when the storage's rect
            // list reports the writes' true footprint (~100 sprites whose union box
            // spans the whole atlas), each rect is staged tightly and copied with
            // its own VkBufferImageCopy in ONE vkCmdCopyBufferToImage. Empty means
            // "stage the one box above". Only set while subRegion.
            Vector<MG_State::GLState::MipmapDirtyRegion> rects;
        };

        Vector<UploadItem> uploadItems;
        Vector<TextureUploadTarget> targets;
        if (outResource.viewType == VK_IMAGE_VIEW_TYPE_CUBE) {
            targets = mipmapTexture.GetUploadTargets();
        } else {
            targets.push_back(uploadTarget);
        }
        const TextureFormatInfo formatInfo = ResolveTextureFormatInfo(mipmapTexture.GetFormat());

        VkDeviceSize stagingSize = 0;
        for (const TextureUploadTarget target : targets) {
            const Uint32 definedMipLevels = GetUploadMipLevelCount(mipmapTexture, target);
            MOBILEGL_ASSERT(definedMipLevels <= outResource.mipLevels,
                            "UploadDirtyMipLevels: defined mip level count %u exceeds backing mip level count %u for textureId=%d target=%s",
                            definedMipLevels, outResource.mipLevels, mipmapTexture.GetExternalIndex(),
                            MG_Util::ConvertTextureUploadTargetToString(target).c_str());

            for (Uint32 level = 0; level < definedMipLevels; ++level) {
                if (!mipmapTexture.IsStorageDirty(target, level)) {
                    continue;
                }

                const auto texelSize = mipmapTexture.GetMipmapTexelSize(target, level);
                const auto byteSize = mipmapTexture.GetMipmapByteSize(target, level);
                if (texelSize.x() <= 0 || texelSize.y() <= 0 || byteSize == 0) {
                    mipmapTexture.MarkStorageDirty(target, level, false);
                    continue;
                }

                const void* source = mipmapTexture.MapMipmapData(target, level);
                if (source == nullptr) {
                    MGLOG_D("%s: MapmipmapData failed at target %s level %d", __func__,
                            MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level);
                    return false;
                }

                UploadItem uploadItem{};
                uploadItem.target = target;
                uploadItem.level = level;
                uploadItem.baseArrayLayer = ResolveUploadArrayLayer(target);
                // Vulkan geometry, like the image this stages into (see SyncTexture): a 1D
                // array's layers move from y to z, where the copy loop's depthSelectsArrayLayer
                // branch turns them into layerCount. The shadow needs no repacking to follow -
                // one layer of a 1D array IS one row of `width` texels, so the tight-packed
                // per-layer copy the swapped size describes reads the same bytes in the same
                // order as the row-major level it replaces.
                uploadItem.texelSize = ToVulkanLevelExtent(mipmapTexture.GetTarget(), texelSize);
                uploadItem.source = source;
                uploadItem.offset = stagingSize;
                uploadItem.uploadByteSize = byteSize;
                if (!formatInfo.expandRgbToRgba &&
                    GetAspectMaskForFormat(outResource.format) == VK_IMAGE_ASPECT_COLOR_BIT) {
                    const auto region = mipmapTexture.GetStorageDirtyRegion(target, level);
                    const SizeT texelCount = static_cast<SizeT>(texelSize.x()) *
                                             static_cast<SizeT>(texelSize.y()) *
                                             static_cast<SizeT>(std::max(texelSize.z(), 1));
                    if (!region.Empty() && !region.CoversWholeLevel(texelSize) && texelCount > 0 &&
                        byteSize % texelCount == 0) {
                        uploadItem.subRegion = true;
                        uploadItem.regionLo = region.lo;
                        uploadItem.regionSize = {region.hi.x() - region.lo.x(), region.hi.y() - region.lo.y(),
                                                 region.hi.z() - region.lo.z()};
                        uploadItem.texelBytes = byteSize / texelCount;
                        uploadItem.uploadByteSize = static_cast<SizeT>(uploadItem.regionSize.x()) *
                                                    static_cast<SizeT>(uploadItem.regionSize.y()) *
                                                    static_cast<SizeT>(uploadItem.regionSize.z()) *
                                                    uploadItem.texelBytes;
                        // Scatter refinement: the storage only hands out its rect list
                        // when the rects' summed area is materially smaller than the
                        // union box (0 otherwise), so taking it always stages fewer
                        // bytes than the box - the very amplification this path exists
                        // to avoid paying twice.
                        MG_State::GLState::MipmapDirtyRegion
                            dirtyRects[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
                        const SizeT dirtyRectCount = mipmapTexture.GetStorageDirtyRects(
                            target, level, dirtyRects, MG_State::GLState::MipmapStorage::kMaxDirtyRects);
                        if (dirtyRectCount >= 2) {
                            uploadItem.rects.assign(dirtyRects, dirtyRects + dirtyRectCount);
                            SizeT rectTexels = 0;
                            for (const auto& rect : uploadItem.rects) {
                                rectTexels += rect.TexelCount();
                            }
                            uploadItem.uploadByteSize = rectTexels * uploadItem.texelBytes;
                        }
                        // The boxes came out of the shadow in GL coordinates, where a 1D
                        // array's layer is the y. They have to follow texelSize across to z or
                        // they would address rows of an image that now has exactly one, and
                        // the staging walk would read the wrong bytes for them. Every byte
                        // count computed above is a product of the three extents, so moving
                        // the axes leaves all of them alone - and an OFFSET lands on a zero y,
                        // not on the extent's one, which is why this is spelled out rather than
                        // handed to ToVulkanLevelExtent.
                        if (mipmapTexture.GetTarget() == TextureTarget::Texture1DArray) {
                            uploadItem.regionLo = {uploadItem.regionLo.x(), 0, uploadItem.regionLo.y()};
                            uploadItem.regionSize = {uploadItem.regionSize.x(), 1,
                                                     uploadItem.regionSize.y()};
                            for (auto& rect : uploadItem.rects) {
                                rect.lo = {rect.lo.x(), 0, rect.lo.y()};
                                rect.hi = {rect.hi.x(), 1, rect.hi.y()};
                            }
                        }
                    }
                }
                if (formatInfo.expandRgbToRgba) {
                    const Bool expanded = ExpandRgbSourceToRgba(source, byteSize, texelSize, formatInfo,
                                                                uploadItem.expandedData);
                    MOBILEGL_ASSERT(expanded,
                                    "UploadDirtyMipLevels: failed to expand RGB textureId=%d target=%s level=%u to RGBA staging data",
                                    mipmapTexture.GetExternalIndex(),
                                    MG_Util::ConvertTextureUploadTargetToString(target).c_str(), level);
                    uploadItem.uploadByteSize = uploadItem.expandedData.size();
                }
                uploadItems.push_back(Move(uploadItem));
                if (!uploadItems.back().expandedData.empty()) {
                    uploadItems.back().source = uploadItems.back().expandedData.data();
                }
                stagingSize += static_cast<VkDeviceSize>(uploadItems.back().uploadByteSize);
            }
        }

        if (uploadItems.empty()) {
            return true;
        }

        // Combined depth-stencil images need per-aspect copies (VkBufferImageCopy aspectMask
        // must have exactly one bit set), so de-interleave the shadow's GL wire format into
        // a depth plane followed by a stencil plane per upload item.
        const VkImageAspectFlags uploadAspectMask = GetAspectMaskForFormat(outResource.format);
        const Bool isCombinedDepthStencil =
            (uploadAspectMask & VK_IMAGE_ASPECT_DEPTH_BIT) && (uploadAspectMask & VK_IMAGE_ASPECT_STENCIL_BIT);
        if (isCombinedDepthStencil) {
            const Bool srcIsD24S8 = outResource.format == VK_FORMAT_D24_UNORM_S8_UINT;
            const Bool srcIsD32FS8 = outResource.format == VK_FORMAT_D32_SFLOAT_S8_UINT;
            if (!srcIsD24S8 && !srcIsD32FS8) {
                MGLOG_E_ONCE("UploadDirtyMipLevels: unsupported combined depth-stencil format %d for textureId=%d",
                        static_cast<Int>(outResource.format), mipmapTexture.GetExternalIndex());
                for (const auto& item : uploadItems) {
                    mipmapTexture.MarkStorageDirty(item.target, item.level, false);
                }
                return true;
            }
            stagingSize = 0;
            for (auto& item : uploadItems) {
                const SizeT texelCount = static_cast<SizeT>(item.texelSize.x()) *
                                         static_cast<SizeT>(item.texelSize.y()) *
                                         static_cast<SizeT>(std::max(item.texelSize.z(), 1));
                const SizeT shadowTexelSize = item.uploadByteSize / std::max<SizeT>(texelCount, 1);
                MOBILEGL_ASSERT(shadowTexelSize == 4 || shadowTexelSize == 8,
                                "UploadDirtyMipLevels: unexpected depth-stencil shadow texel size %zu for textureId=%d",
                                shadowTexelSize, mipmapTexture.GetExternalIndex());
                // Depth plane as the aspect's buffer-copy format (32-bit word for
                // D24: low 24 bits; float for D32F), then one stencil byte per texel.
                Vector<Uint8> deinterleaved(texelCount * 4 + texelCount);
                Uint8* depthPlane = deinterleaved.data();
                Uint8* stencilPlane = deinterleaved.data() + texelCount * 4;
                const Uint8* shadow = static_cast<const Uint8*>(item.source);
                for (SizeT t = 0; t < texelCount; ++t) {
                    if (shadowTexelSize == 8) {
                        // GL_FLOAT_32_UNSIGNED_INT_24_8_REV: float depth, then a word
                        // with stencil in its low 8 bits.
                        float depthValue;
                        Uint32 stencilWord;
                        std::memcpy(&depthValue, shadow + t * 8, sizeof(depthValue));
                        std::memcpy(&stencilWord, shadow + t * 8 + 4, sizeof(stencilWord));
                        if (srcIsD32FS8) {
                            std::memcpy(depthPlane + t * 4, &depthValue, sizeof(depthValue));
                        } else {
                            const float clamped = std::min(std::max(depthValue, 0.0f), 1.0f);
                            const Uint32 depthWord = static_cast<Uint32>(clamped * 16777215.0f + 0.5f);
                            std::memcpy(depthPlane + t * 4, &depthWord, sizeof(depthWord));
                        }
                        stencilPlane[t] = static_cast<Uint8>(stencilWord & 0xFFu);
                    } else {
                        // GL_UNSIGNED_INT_24_8: depth in the high 24 bits, stencil low 8.
                        Uint32 packed;
                        std::memcpy(&packed, shadow + t * 4, sizeof(packed));
                        if (srcIsD24S8) {
                            const Uint32 depthWord = packed >> 8;
                            std::memcpy(depthPlane + t * 4, &depthWord, sizeof(depthWord));
                        } else {
                            const float depthValue = static_cast<float>(packed >> 8) / 16777215.0f;
                            std::memcpy(depthPlane + t * 4, &depthValue, sizeof(depthValue));
                        }
                        stencilPlane[t] = static_cast<Uint8>(packed & 0xFFu);
                    }
                }
                item.expandedData = Move(deinterleaved);
                item.source = item.expandedData.data();
                item.uploadByteSize = item.expandedData.size();
                item.offset = stagingSize;
                stagingSize += static_cast<VkDeviceSize>(item.uploadByteSize);
            }
        }

        // Pure-depth images whose canonical shadow layout differs from the image texel
        // layout (the shadow keeps a full-scale 16/32-bit unorm word or a float; the
        // image may be X8_D24 or a D32_SFLOAT fallback) convert per texel here.
        if (uploadAspectMask == VK_IMAGE_ASPECT_DEPTH_BIT) {
            const TextureInternalFormat depthInternal = mipmapTexture.GetFormat();
            const Bool shadowIsFloat = depthInternal == TextureInternalFormat::DepthComponent32F;
            const Bool dstIsFloat = outResource.format == VK_FORMAT_D32_SFLOAT;
            const Bool dstIsD24Word = outResource.format == VK_FORMAT_X8_D24_UNORM_PACK32;
            stagingSize = 0;
            for (auto& item : uploadItems) {
                const SizeT texelCount = static_cast<SizeT>(item.texelSize.x()) *
                                         static_cast<SizeT>(item.texelSize.y()) *
                                         static_cast<SizeT>(std::max(item.texelSize.z(), 1));
                const SizeT shadowTexelSize = item.uploadByteSize / std::max<SizeT>(texelCount, 1);
                const Bool needsConversion =
                    (dstIsFloat && !shadowIsFloat) || (dstIsD24Word && shadowTexelSize == 4 && !shadowIsFloat);
                if (needsConversion) {
                    Vector<Uint8> converted(texelCount * 4);
                    const Uint8* shadow = static_cast<const Uint8*>(item.source);
                    for (SizeT t = 0; t < texelCount; ++t) {
                        Uint32 wide = 0;
                        if (shadowTexelSize == 2) {
                            Uint16 raw = 0;
                            std::memcpy(&raw, shadow + t * 2, sizeof(raw));
                            wide = (static_cast<Uint32>(raw) << 16) | raw;
                        } else {
                            std::memcpy(&wide, shadow + t * 4, sizeof(wide));
                        }
                        if (dstIsFloat) {
                            const float value = static_cast<float>(static_cast<double>(wide) / 4294967295.0);
                            std::memcpy(converted.data() + t * 4, &value, sizeof(value));
                        } else { // X8_D24: depth in the low 24 bits of a 32-bit word
                            const Uint32 word = wide >> 8;
                            std::memcpy(converted.data() + t * 4, &word, sizeof(word));
                        }
                    }
                    item.expandedData = Move(converted);
                    item.source = item.expandedData.data();
                    item.uploadByteSize = item.expandedData.size();
                }
                item.offset = stagingSize;
                stagingSize += static_cast<VkDeviceSize>(item.uploadByteSize);
            }
        }

        // Rare mid-frame hazard, kept at parity with the old per-upload
        // submits: this image already has an upload recorded in the OPEN batch
        // and has since been referenced by the frame's open recording (drawn).
        // Appending here would merge both uploads into the same pre-frame
        // submission the old code split into two; flush first so the second
        // upload lands in its own later submission, exactly like before.
        if (m_uploadBatchOpen && WasTouchedThisRecording(outResource) &&
            std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), outResource.image) !=
                m_uploadBatchImages.end()) {
            FlushPendingUploads();
        }
        // Bound the staging bytes a single batch can pin before its fence can
        // reclaim them.
        constexpr VkDeviceSize kMaxBatchStagingBytes = 64u * 1024u * 1024u;
        if (m_uploadBatchOpen && m_uploadBatchStagingBytes + stagingSize > kMaxBatchStagingBytes) {
            FlushPendingUploads();
        }

        VkCommandBuffer commandBuffer = EnsureUploadBatchOpen();
        VkBuffer stagingBuffer = VK_NULL_HANDLE;
        VkDeviceSize stagingBase = 0;
        Uint8* mapped = AcquireUploadStagingSpace(stagingSize, stagingBuffer, stagingBase);
        for (const auto& item : uploadItems) {
            Uint8* dst = mapped + item.offset;
            if (!item.subRegion) {
                std::memcpy(dst, item.source, item.uploadByteSize);
                continue;
            }
            // Tight-pack the dirty box(es): the shadow keeps whole-level rows, the
            // staging slice holds only the region (bufferRowLength stays 0). Multi-
            // rect items pack their rects back to back in list order; the copy loop
            // below recomputes the same running offsets.
            const SizeT levelRowBytes = static_cast<SizeT>(item.texelSize.x()) * item.texelBytes;
            const SizeT levelSliceBytes = static_cast<SizeT>(item.texelSize.y()) * levelRowBytes;
            const Uint8* src = static_cast<const Uint8*>(item.source);
            const auto packBox = [&](Uint8* out, const IntVec3& lo, const IntVec3& boxSize) {
                const SizeT boxRowBytes = static_cast<SizeT>(boxSize.x()) * item.texelBytes;
                for (Int z = 0; z < boxSize.z(); ++z) {
                    for (Int y = 0; y < boxSize.y(); ++y) {
                        const Uint8* srcRow = src + static_cast<SizeT>(lo.z() + z) * levelSliceBytes +
                                              static_cast<SizeT>(lo.y() + y) * levelRowBytes +
                                              static_cast<SizeT>(lo.x()) * item.texelBytes;
                        std::memcpy(out + (static_cast<SizeT>(z) * static_cast<SizeT>(boxSize.y()) + y) *
                                              boxRowBytes,
                                    srcRow, boxRowBytes);
                    }
                }
                return static_cast<SizeT>(boxSize.x()) * static_cast<SizeT>(boxSize.y()) *
                       static_cast<SizeT>(boxSize.z()) * item.texelBytes;
            };
            if (!item.rects.empty()) {
                for (const auto& rect : item.rects) {
                    dst += packBox(dst, rect.lo,
                                   IntVec3{rect.hi.x() - rect.lo.x(), rect.hi.y() - rect.lo.y(),
                                           rect.hi.z() - rect.lo.z()});
                }
                continue;
            }
            packBox(dst, item.regionLo, item.regionSize);
        }

        const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format);
        VkPipelineStageFlags uploadSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
        VkAccessFlags uploadSrcAccessMask = 0;
        GetImageTransitionSourceState(outResource.layout, uploadSrcStageMask, uploadSrcAccessMask);
        Bool ok = TransitionImageLayout(commandBuffer, outResource.image,
                                        outResource.layout,
                                        VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                                        uploadSrcStageMask,
                                        VK_PIPELINE_STAGE_TRANSFER_BIT,
                                        uploadSrcAccessMask,
                                        VK_ACCESS_TRANSFER_WRITE_BIT,
                                        aspectMask, 0, outResource.mipLevels);
        MOBILEGL_ASSERT(ok, "TransitionImageLayout to VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL failed");

        // Array textures keep their GL "depth" in VkImage array layers, so the
        // copy must address layerCount, not imageExtent.depth (which is invalid
        // for 2D images and silently dropped every layer past the first).
        const Bool depthSelectsArrayLayer = outResource.viewType == VK_IMAGE_VIEW_TYPE_1D_ARRAY ||
                                            outResource.viewType == VK_IMAGE_VIEW_TYPE_2D_ARRAY ||
                                            outResource.viewType == VK_IMAGE_VIEW_TYPE_CUBE_ARRAY;
        for (const auto& item : uploadItems) {
            if (!item.rects.empty()) {
                // Multi-rect item: one VkBufferImageCopy per rect, all submitted in a
                // single vkCmdCopyBufferToImage. The rect list is pairwise disjoint by
                // construction, so no two copies write the same texels. Multi-rect
                // implies subRegion, which implies a plain color aspect - the combined
                // depth-stencil split below can never see one of these.
                VkBufferImageCopy rectCopies[MG_State::GLState::MipmapStorage::kMaxDirtyRects];
                Uint32 rectCopyCount = 0;
                VkDeviceSize runningOffset = item.offset;
                for (const auto& rect : item.rects) {
                    const IntVec3 rectSize = {rect.hi.x() - rect.lo.x(), rect.hi.y() - rect.lo.y(),
                                              rect.hi.z() - rect.lo.z()};
                    const Uint32 rectDepth = static_cast<Uint32>(std::max(rectSize.z(), 1));
                    VkBufferImageCopy rectCopy{};
                    rectCopy.bufferOffset = stagingBase + runningOffset;
                    rectCopy.bufferRowLength = 0;
                    rectCopy.bufferImageHeight = 0;
                    rectCopy.imageSubresource.aspectMask = aspectMask;
                    rectCopy.imageSubresource.mipLevel = item.level;
                    rectCopy.imageSubresource.baseArrayLayer = item.baseArrayLayer;
                    rectCopy.imageSubresource.layerCount = 1;
                    rectCopy.imageOffset = {rect.lo.x(), rect.lo.y(),
                                            depthSelectsArrayLayer ? 0 : rect.lo.z()};
                    rectCopy.imageExtent = {static_cast<Uint32>(rectSize.x()),
                                            static_cast<Uint32>(rectSize.y()),
                                            depthSelectsArrayLayer ? 1u : rectDepth};
                    if (depthSelectsArrayLayer) {
                        // The GL "depth" axis addresses array layers here, so a partial
                        // z-range narrows the layer span rather than the extent.
                        rectCopy.imageSubresource.baseArrayLayer =
                            item.baseArrayLayer + static_cast<Uint32>(rect.lo.z());
                        rectCopy.imageSubresource.layerCount = rectDepth;
                    }
                    rectCopies[rectCopyCount++] = rectCopy;
                    runningOffset += static_cast<VkDeviceSize>(rect.TexelCount() * item.texelBytes);
                }
                vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image,
                                       VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rectCopyCount, rectCopies);
                continue;
            }
            const Uint32 depthOrLayers = item.texelSize.z() > 0 ? static_cast<Uint32>(item.texelSize.z()) : 1u;
            VkBufferImageCopy copy{};
            copy.bufferOffset = stagingBase + item.offset;
            copy.bufferRowLength = 0;
            copy.bufferImageHeight = 0;
            copy.imageSubresource.aspectMask = aspectMask;
            copy.imageSubresource.mipLevel = item.level;
            copy.imageSubresource.baseArrayLayer = item.baseArrayLayer;
            copy.imageSubresource.layerCount = depthSelectsArrayLayer ? depthOrLayers : 1;
            copy.imageOffset = {0, 0, 0};
            copy.imageExtent = {static_cast<Uint32>(item.texelSize.x()), static_cast<Uint32>(item.texelSize.y()),
                                depthSelectsArrayLayer ? 1u : depthOrLayers};
            if (item.subRegion) {
                const Uint32 regionDepth = static_cast<Uint32>(std::max(item.regionSize.z(), 1));
                copy.imageOffset = {item.regionLo.x(), item.regionLo.y(),
                                    depthSelectsArrayLayer ? 0 : item.regionLo.z()};
                copy.imageExtent = {static_cast<Uint32>(item.regionSize.x()),
                                    static_cast<Uint32>(item.regionSize.y()),
                                    depthSelectsArrayLayer ? 1u : regionDepth};
                if (depthSelectsArrayLayer) {
                    // The GL "depth" axis addresses array layers here, so a partial
                    // z-range narrows the layer span rather than the extent.
                    copy.imageSubresource.baseArrayLayer =
                        item.baseArrayLayer + static_cast<Uint32>(item.regionLo.z());
                    copy.imageSubresource.layerCount = regionDepth;
                }
            }
            if (isCombinedDepthStencil) {
                const SizeT texelCount = static_cast<SizeT>(item.texelSize.x()) *
                                         static_cast<SizeT>(item.texelSize.y()) *
                                         static_cast<SizeT>(std::max(item.texelSize.z(), 1));
                VkBufferImageCopy depthCopy = copy;
                depthCopy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
                VkBufferImageCopy stencilCopy = copy;
                stencilCopy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_STENCIL_BIT;
                stencilCopy.bufferOffset = stagingBase + item.offset + static_cast<VkDeviceSize>(texelCount) * 4;
                const VkBufferImageCopy copies[2] = {depthCopy, stencilCopy};
                vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image,
                                       VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 2, copies);
                continue;
            }
            vkCmdCopyBufferToImage(commandBuffer, stagingBuffer, outResource.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                                   1, &copy);
        }

        const VkImageLayout finalLayout = ResolveSampledReadOnlyLayout(aspectMask);
        VkImageLayout uploadLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
        ok = TransitionImageLayout(commandBuffer, outResource.image,
                                   uploadLayout,
                                   finalLayout,
                                   VK_PIPELINE_STAGE_TRANSFER_BIT,
                                   s_sampledReadStages,
                                   VK_ACCESS_TRANSFER_WRITE_BIT,
                                   VK_ACCESS_SHADER_READ_BIT,
                                   aspectMask, 0, outResource.mipLevels);
        MOBILEGL_ASSERT(ok, "TransitionImageLayout to sampled read-only layout failed");
        outResource.layout = finalLayout;

        // Ordering argument (replaces the old immediate per-texture submit):
        // this upload is RECORDED into the shared batch command buffer, which
        // FlushPendingUploads submits - with one vkQueueSubmit and one pooled
        // fence for the whole batch - strictly BEFORE any other submission on
        // the same queue whose commands could consume the image: the renderer
        // flushes at every frame-command-buffer submit (mid-frame flush,
        // readback, Present), and the texture manager flushes before the
        // preserve-on-recreate copy and before deferring an image the batch
        // references. The frame command buffer therefore still lands behind
        // the uploads on the queue, so a texture uploaded and then immediately
        // sampled in the same frame sees its data exactly as it did when each
        // upload was its own submit. No fence is waited here, for the same
        // reason as before: the batch queues behind the previous frame's
        // rendering, and a synchronous wait would drain the GPU; the staging
        // blocks/command buffer are parked on the reclaim list at flush time
        // and recycled once the batch fence signals.
        if (std::find(m_uploadBatchImages.begin(), m_uploadBatchImages.end(), outResource.image) ==
            m_uploadBatchImages.end()) {
            m_uploadBatchImages.push_back(outResource.image);
        }
        m_uploadBatchStagingBytes += stagingSize;

        if (!ok) {
            MGLOG_D("%s: texture upload cmd failed", __func__);
            return false;
        }
        for (const auto& item : uploadItems) {
            mipmapTexture.MarkStorageDirty(item.target, item.level, false);
        }
        outResource.layout = finalLayout;
        // Large batches flush right away instead of riding until the frame
        // submit: a big copy amortizes its own vkQueueSubmit, submitting it
        // early lets the GPU overlap the copy with the rest of the frame's
        // CPU recording (measurably faster than a frame-tail burst), and the
        // frame-tail burst pattern was observed to leave the GPU in a
        // latency state that taxes whatever runs next. Small uploads keep
        // accumulating, so a lightmap+sprite frame still costs one submit.
        constexpr VkDeviceSize kEagerUploadFlushBytes = 128u * 1024u;
        if (m_uploadBatchStagingBytes >= kEagerUploadFlushBytes) {
            FlushPendingUploads();
        }
        return true;
    }

    Bool VkTextureManager::CheckMipmapCompleteness(const MG_State::GLState::ITextureObject& texture,
                                                   TextureUploadTarget& outTarget,
                                                   IntVec3& outTexelSize,
                                                   SizeT& outByteSize,
                                                   Uint32& outMipLevelCount) {
        const auto* mipTexture = MG_State::GLState::AsMipmapTexture(&texture);
        if (!mipTexture) {
            MGLOG_D("%s: not TextureObjectMipmap", __func__);
            return false;
        }
        const auto& targets = texture.GetUploadTargets();
        if (targets.empty()) {
            MGLOG_D("%s: upload target empty", __func__);
            return false;
        }

        for (const auto target : targets) {
            const Uint32 mipLevelCount = GetUploadMipLevelCount(*mipTexture, target);
            if (mipLevelCount == 0) {
                MGLOG_D("%s: mipLevelCount == 0", __func__);
                continue;
            }

            // Backing VkImage allocation still uses storage mip 0 as the physical image extent.
            // GL_TEXTURE_BASE_LEVEL / MAX_LEVEL are applied later when building the sampled view.
            const auto storageBaseTexelSize = mipTexture->GetMipmapTexelSize(target, 0);
            const auto storageBaseByteSize = mipTexture->GetMipmapByteSize(target, 0);
            if (storageBaseTexelSize.x() <= 0 || storageBaseTexelSize.y() <= 0 /*|| storageBaseByteSize == 0*/) {
                continue;
            }

            outTarget = target;
            outTexelSize = storageBaseTexelSize;
            outByteSize = storageBaseByteSize;
            outMipLevelCount = mipLevelCount;
            return true;
        }
        MGLOG_D("%s: no valid target or mipmap", __func__);
        return false;
    }

    Uint32 VkTextureManager::GetUploadMipLevelCount(const MG_State::GLState::TextureObjectMipmap& texture,
                                                    TextureUploadTarget target) {
        const Uint totalLevelCount = texture.GetMipmapLevelCount();
        if (totalLevelCount == 0) {
            return 0;
        }

        Uint32 validLevelCount = 0;
        for (Uint level = 0; level < totalLevelCount; ++level) {
            const auto size = texture.GetMipmapTexelSize(target, level);
            const auto byteSize = texture.GetMipmapByteSize(target, level);
            if (size.x() <= 0 || size.y() <= 0 /*|| byteSize == 0*/) {
                break;
            }
            ++validLevelCount;
        }
        return validLevelCount;
    }

    void VkTextureManager::ResolveViewMipRange(const MG_State::GLState::ITextureObject& texture, Uint32 mipLevels,
                                               Uint32& outBaseMipLevel, Uint32& outLevelCount) {
        MOBILEGL_ASSERT(mipLevels > 0, "ResolveViewMipRange: mipLevels must be > 0");

        Uint32 definedMipLevels = mipLevels;
        if (const auto* mipTexture = MG_State::GLState::AsMipmapTexture(&texture)) {
            const auto& targets = texture.GetUploadTargets();
            for (const auto target : targets) {
                const Uint32 uploadMipLevels = GetUploadMipLevelCount(*mipTexture, target);
                if (uploadMipLevels == 0) {
                    continue;
                }
                definedMipLevels = std::min(mipLevels, uploadMipLevels);
                break;
            }
        }
        MOBILEGL_ASSERT(definedMipLevels > 0, "ResolveViewMipRange: texture has no defined mip levels");

        const auto& levelRange = texture.GetLevelRange();
        const Uint32 maxAvailableMipLevel = definedMipLevels - 1;
        const Uint32 requestedBaseMipLevel = std::min(static_cast<Uint32>(levelRange.x()), maxAvailableMipLevel);
        Uint32 requestedMaxMipLevel = std::min(static_cast<Uint32>(levelRange.y()), maxAvailableMipLevel);
        if (requestedMaxMipLevel < requestedBaseMipLevel) {
            requestedMaxMipLevel = requestedBaseMipLevel;
        }

        outBaseMipLevel = requestedBaseMipLevel;
        outLevelCount = requestedMaxMipLevel - requestedBaseMipLevel + 1;
    }

    VkImageAspectFlags VkTextureManager::GetAspectMaskForFormat(VkFormat format) {
        switch (format) {
        case VK_FORMAT_D16_UNORM:
        case VK_FORMAT_X8_D24_UNORM_PACK32:
        case VK_FORMAT_D32_SFLOAT:
            return VK_IMAGE_ASPECT_DEPTH_BIT;
        case VK_FORMAT_S8_UINT:
            return VK_IMAGE_ASPECT_STENCIL_BIT;
        case VK_FORMAT_D16_UNORM_S8_UINT:
        case VK_FORMAT_D24_UNORM_S8_UINT:
        case VK_FORMAT_D32_SFLOAT_S8_UINT:
            return VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT;
        default:
            return VK_IMAGE_ASPECT_COLOR_BIT;
        }
    }

    VkImageAspectFlags VkTextureManager::ResolveSampledImageViewAspectMask(VkImageAspectFlags imageAspect,
                                                                           GLenum depthStencilTextureMode) {
        if ((imageAspect & VK_IMAGE_ASPECT_COLOR_BIT) != 0) {
            return VK_IMAGE_ASPECT_COLOR_BIT;
        }
        // A sampled view of a combined depth/stencil image may name exactly one aspect
        // (VUID-VkDescriptorImageInfo-imageView-01976), and GL_DEPTH_STENCIL_TEXTURE_MODE is
        // what picks it - the whole content of GL_ARB_stencil_texturing. Depth stays the
        // default, so nothing that never sets the mode changes shape. The texture's params
        // version moves with the mode, which is what makes the cached views be rebuilt.
        if (depthStencilTextureMode == GL_STENCIL_INDEX && (imageAspect & VK_IMAGE_ASPECT_STENCIL_BIT) != 0) {
            return VK_IMAGE_ASPECT_STENCIL_BIT;
        }
        if ((imageAspect & VK_IMAGE_ASPECT_DEPTH_BIT) != 0) {
            return VK_IMAGE_ASPECT_DEPTH_BIT;
        }
        if ((imageAspect & VK_IMAGE_ASPECT_STENCIL_BIT) != 0) {
            return VK_IMAGE_ASPECT_STENCIL_BIT;
        }
        return imageAspect;
    }

    VkFormat VkTextureManager::ResolveSampledImageViewFormat(VkFormat imageFormat,
                                                              SamplerNumericDomain numericDomain) {
        // Depth/stencil images always sample through the existing depth-aspect sampledView.
        // Combined formats (D24S8, D32FS8) are multi-numeric, so vkuFormatIsSampledFloat is
        // false for them by design, yet their depth aspect reads as float in every GL depth
        // texture mode; Vulkan also forbids reinterpreting them through color-class views.
        // Integer domains keep the same view (pre-reinterpretation behavior for stencil-index
        // style access) rather than failing the draw.
        if (vkuFormatIsDepthOrStencil(imageFormat)) {
            return imageFormat;
        }
        if (imageFormat == VK_FORMAT_UNDEFINED || numericDomain == SamplerNumericDomain::Unknown ||
            FormatMatchesSamplerNumericDomain(imageFormat, numericDomain)) {
            return imageFormat;
        }
        if (!IsMutableStorageImageFormat(imageFormat)) {
            return VK_FORMAT_UNDEFINED;
        }

        // Preserve component ordering and bit widths. This selects R32_UINT for an R32_SFLOAT
        // texture sampled by a usampler rather than an arbitrary member (such as
        // R8G8B8A8_UINT) of Vulkan's broad 32-bit compatibility class.
        for (Int candidateValue = static_cast<Int>(VK_FORMAT_R4G4_UNORM_PACK8);
             candidateValue <= static_cast<Int>(VK_FORMAT_ASTC_12x12_SRGB_BLOCK);
             ++candidateValue) {
            const VkFormat candidate = static_cast<VkFormat>(candidateValue);
            if (!IsMutableStorageImageFormat(candidate) ||
                !FormatMatchesSamplerNumericDomain(candidate, numericDomain) ||
                !HasMatchingColorComponentLayout(imageFormat, candidate) ||
                !AreSampledImageViewFormatsCompatible(imageFormat, candidate)) {
                continue;
            }

            // If an integer backing is intentionally bit-read through a float sampler, require
            // a true floating-point view. Normalized/scaled views satisfy OpTypeFloat but apply
            // an unrelated numeric conversion to those bits.
            if (numericDomain == SamplerNumericDomain::Float && !vkuFormatIsSFLOAT(candidate)) {
                continue;
            }
            return candidate;
        }
        return VK_FORMAT_UNDEFINED;
    }

    Bool VkTextureManager::AreSampledImageViewFormatsCompatible(VkFormat imageFormat, VkFormat viewFormat) {
        if (imageFormat == viewFormat) {
            return true;
        }
        return IsMutableStorageImageFormat(imageFormat) && IsMutableStorageImageFormat(viewFormat) &&
               vkuFormatCompatibilityClass(imageFormat) == vkuFormatCompatibilityClass(viewFormat);
    }

    Bool VkTextureManager::AreStorageImageViewFormatsCompatible(VkFormat imageFormat, VkFormat viewFormat) {
        if (imageFormat == viewFormat) {
            return true;
        }
        return IsMutableStorageImageFormat(imageFormat) && IsMutableStorageImageFormat(viewFormat) &&
               vkuFormatCompatibilityClass(imageFormat) == vkuFormatCompatibilityClass(viewFormat);
    }
} // namespace MobileGL::MG_Backend::DirectVulkan
