diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index ee4d3c74..0b681470 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -8530,24 +8530,106 @@ void main() { MOBILEGL_ASSERT(dstRestored, "%s: failed to restore destination image layout", __func__); } + namespace { + // GL hands CopyImageSubData ONE z/depth pair and lets the texture target decide what it + // means. Vulkan splits that meaning across two different fields of VkImageCopy, chosen by + // the image type: + // + // VK_IMAGE_TYPE_3D - slices live on the z axis: srcOffset.z/dstOffset.z select them and + // extent.depth counts them. The subresource layer range must stay + // (0, 1): Vulkan reads a 3D image as a single layer whose depth is + // the mip level's depth (VUID-VkImageCopy-apiVersion-07932/-07933). + // everything else - slices live in the array dimension: baseArrayLayer selects them and + // layerCount counts them, while offset.z stays 0 and (when neither + // endpoint is 3D) extent.depth stays 1. + // + // A mixed 2D-array <-> 3D pair is legal because maintenance1 - core since Vulkan 1.1 - + // relaxed the old "layerCounts must match" rule into "the 3D side's extent.depth must + // equal the array side's layerCount". + struct CopyImageEndpoint { + // True for a VK_IMAGE_TYPE_3D image, i.e. slices ride the z axis, not the layer axis. + Bool slicesAreDepth = false; + // The GL z offset, kept in whichever field this endpoint's image type reads it from. + Uint32 baseSlice = 0; + // Slices this endpoint can address at the selected mip level; the copy range check + // needs the level's depth for a 3D image (3D mips shrink in z) and the image's array + // size for a layered one (array layers do not shrink). + Uint32 availableSlices = 1; + + Uint32 BaseArrayLayer() const { return slicesAreDepth ? 0u : baseSlice; } + Int32 OffsetZ() const { return slicesAreDepth ? static_cast(baseSlice) : 0; } + }; + + Bool TryResolveCopyImageEndpoint(TextureTarget target, + const VkTextureManager::TextureResource& resource, Uint32 mipLevel, + GLint glZ, GLsizei glDepth, CopyImageEndpoint& outEndpoint) { + if (glZ < 0 || glDepth <= 0) { + return false; + } + const Uint32 baseSlice = static_cast(glZ); + switch (target) { + case TextureTarget::Texture1D: + case TextureTarget::Texture2D: + case TextureTarget::TextureRectangle: + case TextureTarget::Texture2DMultisample: + // Not layered at all: GL still requires the z/depth pair, and it can only name the + // one slice these targets have. + outEndpoint = {}; + return baseSlice == 0 && glDepth == 1; + case TextureTarget::Texture3D: + outEndpoint.slicesAreDepth = true; + outEndpoint.baseSlice = baseSlice; + outEndpoint.availableSlices = std::max(1u, resource.depth >> mipLevel); + return true; + case TextureTarget::Texture2DArray: + case TextureTarget::Texture2DMultisampleArray: + case TextureTarget::TextureCubeMap: + case TextureTarget::TextureCubeMapArray: + // A cube map is an array of six faces here (see TryResolveTextureShapeInfo), and GL + // numbers its faces on the same z axis an array texture numbers its layers, so both + // arrive as a plain layer range. + outEndpoint.slicesAreDepth = false; + outEndpoint.baseSlice = baseSlice; + outEndpoint.availableSlices = resource.arrayLayers; + return true; + default: + // GL_TEXTURE_1D_ARRAY carries its layers on the Y axis (srcY/srcHeight), which + // would have to be remapped against a Vulkan extent that also has to stay height 1 + // for a VK_IMAGE_TYPE_1D image; GL_TEXTURE_BUFFER has no image at all. Declined + // rather than mis-addressed. + return false; + } + } + } // namespace + void VulkanRenderer::CopyImageSubData(const SharedPtr& srcTexture, GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY, GLint srcZ, const SharedPtr& dstTexture, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) { - MOBILEGL_ASSERT(srcWidth > 0 && srcHeight > 0 && srcDepth > 0, - "CopyImageSubData requires positive copy dimensions."); MOBILEGL_ASSERT(srcTexture != nullptr && dstTexture != nullptr, "CopyImageSubData requires valid source and destination textures."); + // The frontend already declines a zero or negative extent, so anything else here is a + // caller MobileGL wrote - but it still reaches vkCmdCopyImage in a release build, and a + // zero extent.depth is as invalid as a zero width. + if (srcWidth <= 0 || srcHeight <= 0 || srcDepth <= 0) { + MGLOG_E_ONCE("%s: non-positive copy extent %dx%dx%d; declining the copy", __func__, srcWidth, srcHeight, + srcDepth); + return; + } const auto srcTextureTarget = MG_Util::ConvertGLEnumToTextureTarget(srcTarget); const auto dstTextureTarget = MG_Util::ConvertGLEnumToTextureTarget(dstTarget); - MOBILEGL_ASSERT(srcTextureTarget == TextureTarget::Texture2D && dstTextureTarget == TextureTarget::Texture2D, - "CopyImageSubData currently only supports GL_TEXTURE_2D sources and destinations."); - MOBILEGL_ASSERT(srcDepth == 1 && srcZ == 0 && dstZ == 0, - "CopyImageSubData currently only supports single-layer 2D copies."); - MOBILEGL_ASSERT(srcTexture.get() != dstTexture.get(), - "CopyImageSubData does not support in-place texture copies yet."); + // Both endpoints of a same-image copy would have to share one VkImageLayout, so the + // TRANSFER_SRC/TRANSFER_DST pair below cannot express it (it needs VK_IMAGE_LAYOUT_GENERAL + // and an overlap check). Refused outright, and refused for real rather than through an + // assertion the release build drops: recording the pair anyway is a validation error and, + // on a tiler, a copy whose source has already been overwritten. + if (srcTexture.get() == dstTexture.get()) { + MGLOG_E_ONCE("%s: in-place copy on textureId=%d is not supported; declining the copy", __func__, + srcTexture->GetExternalIndex()); + return; + } auto& frame = m_frameContext.GetCurrent(); if (!frame.isCommandRecording) { @@ -8616,29 +8698,89 @@ void main() { return; } + // The supported envelope, replacing the "GL_TEXTURE_2D only" assertion that used to stand + // here: every target whose slices this function can address on one of the two Vulkan axes. + // A refusal has to be a real decline, not an assertion - the assertion compiled to nothing + // in a release build and the unsupported shape reached vkCmdCopyImage anyway. + CopyImageEndpoint srcEndpoint; + CopyImageEndpoint dstEndpoint; + if (!TryResolveCopyImageEndpoint(srcTextureTarget, *srcResource, srcMipLevel, srcZ, srcDepth, srcEndpoint) || + !TryResolveCopyImageEndpoint(dstTextureTarget, *dstResource, dstMipLevel, dstZ, srcDepth, dstEndpoint)) { + MGLOG_E_ONCE("%s: unsupported target pair src=%s dst=%s (srcZ=%d dstZ=%d depth=%d); declining the copy", + __func__, MG_Util::ConvertTextureTargetToString(srcTextureTarget).c_str(), + MG_Util::ConvertTextureTargetToString(dstTextureTarget).c_str(), srcZ, dstZ, srcDepth); + return; + } + // The slice half of the region-bounds guard above. A layered endpoint's bound is NOT the + // mip-0 2D extent: an array texture is bounded by its layer count (which no mip level + // shrinks) and a 3D texture by the selected level's depth (which every level halves), so + // both come from the endpoint that resolved them. + const Uint32 copySliceCount = static_cast(srcDepth); + if (srcEndpoint.baseSlice + copySliceCount > srcEndpoint.availableSlices || + dstEndpoint.baseSlice + copySliceCount > dstEndpoint.availableSlices) { + MGLOG_E_ONCE("%s: slice range outside image bounds (srcZ=%d of %u, dstZ=%d of %u, depth=%d); " + "declining the copy", + __func__, srcZ, srcEndpoint.availableSlices, dstZ, dstEndpoint.availableSlices, srcDepth); + return; + } + const Bool clearReady = MaterializePendingClearForTexture(frame.commandBuffer, *srcTexture); MOBILEGL_ASSERT(clearReady, "%s: failed to materialize pending clear for source textureId=%d", __func__, srcTexture->GetExternalIndex()); + // A clear still parked on the destination would otherwise materialize AFTER this copy and + // wipe the texels it just wrote. + const Bool dstClearReady = MaterializePendingClearForTexture(frame.commandBuffer, *dstTexture); + MOBILEGL_ASSERT(dstClearReady, "%s: failed to materialize pending clear for destination textureId=%d", + __func__, dstTexture->GetExternalIndex()); const VkImageLayout srcOriginalLayout = srcResource->layout; const VkImageLayout dstOriginalLayout = dstResource->layout; - MOBILEGL_ASSERT(srcOriginalLayout != VK_IMAGE_LAYOUT_UNDEFINED, - "CopyImageSubData source image has undefined layout."); - const VkImageLayout dstRestoreLayout = dstOriginalLayout == VK_IMAGE_LAYOUT_UNDEFINED - ? ((copyAspectMask & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT)) != 0 + // A layout of UNDEFINED means nothing has ever been written to the image, which on the + // SOURCE side is glTexStorage without an upload: legal GL, and the texels it copies are + // undefined by the same spec sentence that lets the application ask. Both sides therefore + // take the same shape - transition the whole image out of UNDEFINED and settle it on a + // real layout afterwards, since UNDEFINED is not a layout a barrier may transition BACK to. + const auto resolveRestoreLayout = [copyAspectMask](VkImageLayout originalLayout) { + if (originalLayout != VK_IMAGE_LAYOUT_UNDEFINED) { + return originalLayout; + } + return (copyAspectMask & (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) - : dstOriginalLayout; + : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + }; + const VkImageLayout srcRestoreLayout = resolveRestoreLayout(srcOriginalLayout); + const VkImageLayout dstRestoreLayout = resolveRestoreLayout(dstOriginalLayout); VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags srcAccessMask = 0; GetImageTransitionSourceState(srcOriginalLayout, srcStageMask, srcAccessMask); VkImageLayout srcCopyLayout = srcOriginalLayout; - Bool srcReady = VkTextureManager::TransitionImageLayout( - frame.commandBuffer, srcResource->image, srcCopyLayout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - srcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, - srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, copyAspectMask, srcMipLevel, 1); - MOBILEGL_ASSERT(srcReady, "%s: failed to transition source image", __func__); + // The barrier has to name every layer the copy touches, not just layer 0 - otherwise the + // slice fix above lands the copy on layers the barrier never transitioned, which is the + // same defect one level down. TransitionImageLayout always starts its range at + // baseArrayLayer 0, so VK_REMAINING_ARRAY_LAYERS is the whole range and a superset of + // [baseSlice, baseSlice + depth). + // + // Not `arrayLayers`, which is 1 for a 3D image: MobileGL creates 3D images + // 2D_ARRAY_COMPATIBLE, and a literal 1 on one of those means "every depth slice" today but + // "depth slice 0" once VK_KHR_maintenance9 is enabled - i.e. it would silently become a + // single-slice barrier again on a newer driver. The validation layer says so by name. + static constexpr Uint32 kAllLayers = VK_REMAINING_ARRAY_LAYERS; + if (srcOriginalLayout == VK_IMAGE_LAYOUT_UNDEFINED) { + Bool srcReady = VkTextureManager::TransitionImageLayout( + frame.commandBuffer, srcResource->image, srcResource->layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + srcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, + srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, + srcResource->aspect, 0, srcResource->mipLevels, kAllLayers); + MOBILEGL_ASSERT(srcReady, "%s: failed to transition undefined source image", __func__); + srcCopyLayout = srcResource->layout; + } else { + Bool srcReady = VkTextureManager::TransitionImageLayout( + frame.commandBuffer, srcResource->image, srcCopyLayout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + srcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, + srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, copyAspectMask, srcMipLevel, 1, kAllLayers); + MOBILEGL_ASSERT(srcReady, "%s: failed to transition source image", __func__); + } VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags dstAccessMask = 0; @@ -8649,29 +8791,42 @@ void main() { frame.commandBuffer, dstResource->image, dstResource->layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, dstStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, dstAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT, - dstResource->aspect, 0, dstResource->mipLevels, dstResource->arrayLayers); + dstResource->aspect, 0, dstResource->mipLevels, kAllLayers); MOBILEGL_ASSERT(dstReady, "%s: failed to transition undefined destination image", __func__); dstCopyLayout = dstResource->layout; } else { Bool dstReady = VkTextureManager::TransitionImageLayout( frame.commandBuffer, dstResource->image, dstCopyLayout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, dstStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, - dstAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT, copyAspectMask, dstMipLevel, 1); + dstAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT, copyAspectMask, dstMipLevel, 1, kAllLayers); MOBILEGL_ASSERT(dstReady, "%s: failed to transition destination image", __func__); } + // The GL slice count reaches Vulkan on the layer axis of whichever endpoint is NOT 3D, and + // on extent.depth as soon as either endpoint IS: a 3D image's subresource is always the + // single layer (0, 1) and its slices are counted by the depth of the copy extent. With two + // non-3D endpoints both layer counts carry it and extent.depth stays 1. + const Bool copyCrossesDepthAxis = srcEndpoint.slicesAreDepth || dstEndpoint.slicesAreDepth; VkImageCopy copyRegion{}; copyRegion.srcSubresource.aspectMask = copyAspectMask; copyRegion.srcSubresource.mipLevel = srcMipLevel; - copyRegion.srcSubresource.baseArrayLayer = 0; - copyRegion.srcSubresource.layerCount = 1; - copyRegion.srcOffset = {srcX, srcY, 0}; + copyRegion.srcSubresource.baseArrayLayer = srcEndpoint.BaseArrayLayer(); + copyRegion.srcSubresource.layerCount = srcEndpoint.slicesAreDepth ? 1u : copySliceCount; + copyRegion.srcOffset = {srcX, srcY, srcEndpoint.OffsetZ()}; copyRegion.dstSubresource.aspectMask = copyAspectMask; copyRegion.dstSubresource.mipLevel = dstMipLevel; - copyRegion.dstSubresource.baseArrayLayer = 0; - copyRegion.dstSubresource.layerCount = 1; - copyRegion.dstOffset = {dstX, dstY, 0}; - copyRegion.extent = {static_cast(srcWidth), static_cast(srcHeight), 1}; + copyRegion.dstSubresource.baseArrayLayer = dstEndpoint.BaseArrayLayer(); + copyRegion.dstSubresource.layerCount = dstEndpoint.slicesAreDepth ? 1u : copySliceCount; + copyRegion.dstOffset = {dstX, dstY, dstEndpoint.OffsetZ()}; + copyRegion.extent = {static_cast(srcWidth), static_cast(srcHeight), + copyCrossesDepthAxis ? copySliceCount : 1u}; + MGLOG_D("CopyImageSubData: src(target=%s level=%u layer=%u+%u z=%d) -> dst(target=%s level=%u layer=%u+%u " + "z=%d) extent=[%d x %d x %u]", + MG_Util::ConvertTextureTargetToString(srcTextureTarget).c_str(), srcMipLevel, + copyRegion.srcSubresource.baseArrayLayer, copyRegion.srcSubresource.layerCount, + copyRegion.srcOffset.z, MG_Util::ConvertTextureTargetToString(dstTextureTarget).c_str(), dstMipLevel, + copyRegion.dstSubresource.baseArrayLayer, copyRegion.dstSubresource.layerCount, + copyRegion.dstOffset.z, srcWidth, srcHeight, copyRegion.extent.depth); vkCmdCopyImage(frame.commandBuffer, srcResource->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dstResource->image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, @@ -8679,12 +8834,21 @@ void main() { VkPipelineStageFlags srcRestoreStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags srcRestoreAccessMask = 0; - GetImageTransitionDestinationState(srcOriginalLayout, srcRestoreStageMask, srcRestoreAccessMask); - Bool srcRestored = VkTextureManager::TransitionImageLayout( - frame.commandBuffer, srcResource->image, srcCopyLayout, srcOriginalLayout, - VK_PIPELINE_STAGE_TRANSFER_BIT, srcRestoreStageMask, - VK_ACCESS_TRANSFER_READ_BIT, srcRestoreAccessMask, copyAspectMask, srcMipLevel, 1); - MOBILEGL_ASSERT(srcRestored, "%s: failed to restore source image layout", __func__); + GetImageTransitionDestinationState(srcRestoreLayout, srcRestoreStageMask, srcRestoreAccessMask); + if (srcOriginalLayout == VK_IMAGE_LAYOUT_UNDEFINED) { + Bool srcRestored = VkTextureManager::TransitionImageLayout( + frame.commandBuffer, srcResource->image, srcResource->layout, srcRestoreLayout, + VK_PIPELINE_STAGE_TRANSFER_BIT, srcRestoreStageMask, + VK_ACCESS_TRANSFER_READ_BIT, srcRestoreAccessMask, + srcResource->aspect, 0, srcResource->mipLevels, kAllLayers); + MOBILEGL_ASSERT(srcRestored, "%s: failed to restore undefined source image layout", __func__); + } else { + Bool srcRestored = VkTextureManager::TransitionImageLayout( + frame.commandBuffer, srcResource->image, srcCopyLayout, srcRestoreLayout, + VK_PIPELINE_STAGE_TRANSFER_BIT, srcRestoreStageMask, + VK_ACCESS_TRANSFER_READ_BIT, srcRestoreAccessMask, copyAspectMask, srcMipLevel, 1, kAllLayers); + MOBILEGL_ASSERT(srcRestored, "%s: failed to restore source image layout", __func__); + } VkPipelineStageFlags dstRestoreStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags dstRestoreAccessMask = 0; @@ -8694,13 +8858,13 @@ void main() { frame.commandBuffer, dstResource->image, dstResource->layout, dstRestoreLayout, VK_PIPELINE_STAGE_TRANSFER_BIT, dstRestoreStageMask, VK_ACCESS_TRANSFER_WRITE_BIT, dstRestoreAccessMask, - dstResource->aspect, 0, dstResource->mipLevels, dstResource->arrayLayers); + dstResource->aspect, 0, dstResource->mipLevels, kAllLayers); MOBILEGL_ASSERT(dstRestored, "%s: failed to restore undefined destination image layout", __func__); } else { Bool dstRestored = VkTextureManager::TransitionImageLayout( frame.commandBuffer, dstResource->image, dstCopyLayout, dstRestoreLayout, VK_PIPELINE_STAGE_TRANSFER_BIT, dstRestoreStageMask, - VK_ACCESS_TRANSFER_WRITE_BIT, dstRestoreAccessMask, copyAspectMask, dstMipLevel, 1); + VK_ACCESS_TRANSFER_WRITE_BIT, dstRestoreAccessMask, copyAspectMask, dstMipLevel, 1, kAllLayers); MOBILEGL_ASSERT(dstRestored, "%s: failed to restore destination image layout", __func__); } @@ -9661,11 +9825,14 @@ void main() { VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags srcAccessMask = 0; GetImageTransitionSourceState(originalLayout, srcStageMask, srcAccessMask); + // The copy below reads EVERY layer of the level, so the barrier has to name every layer + // too; a layerCount of 1 left an array texture's layers 1.. in whatever layout they were + // last left in while the transfer read them. Bool ok = VkTextureManager::TransitionImageLayout( frame.commandBuffer, resource->image, resource->layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, srcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, resource->aspect, - static_cast(level), 1); + static_cast(level), 1, VK_REMAINING_ARRAY_LAYERS); MOBILEGL_ASSERT(ok, "%s: failed to transition texture image", __func__); VkBufferImageCopy copyRegion{}; @@ -9685,7 +9852,7 @@ void main() { frame.commandBuffer, resource->image, resource->layout, originalLayout, VK_PIPELINE_STAGE_TRANSFER_BIT, restoreStageMask, VK_ACCESS_TRANSFER_READ_BIT, restoreAccessMask, resource->aspect, - static_cast(level), 1); + static_cast(level), 1, VK_REMAINING_ARRAY_LAYERS); MOBILEGL_ASSERT(ok, "%s: failed to restore texture image layout", __func__); if (!SubmitReadbackCommandsAndWait(frame)) { diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 920db51e..8f6ae23a 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -79,6 +79,7 @@ add_executable(MobileGLIntegrationTest Scenarios/XfbCaptureBufferReuseScenario.cpp Scenarios/VertexArrayEnableDisableScenario.cpp Scenarios/CopyImageLevelRangeScenario.cpp + Scenarios/CopyImageLayeredScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CopyImageLayeredScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CopyImageLayeredScenario.cpp new file mode 100644 index 00000000..ef2d76d9 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CopyImageLayeredScenario.cpp @@ -0,0 +1,331 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CopyImageLayeredScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - glCopyImageSubData MOVES EVERY SLICE IT WAS ASKED FOR, NOT JUST SLICE 0. +// +// KHR-GL43.copy_image.functional_* copies a whole 12-layer region in one call whenever both +// endpoints are layered, i.e. for the four target pairs 2d_array->2d_array, 2d_array->3d, +// 3d->2d_array and 3d->3d. DirectVulkan built its VkImageCopy with baseArrayLayer 0, layerCount 1 +// and srcOffset.z 0 no matter what the call asked for, so slice 0 landed correctly and slices 1..N +// were never written - 64 conformance cases (16 compatible format pairs x those 4 pairs) failing +// with "first mismatch at [x, y, 1]", the first texel of the first slice the copy skipped. +// +// The reason one hardcode covered both shapes wrongly is that GL states a layered copy ONE way - +// srcZ/dstZ and srcDepth - while Vulkan states it two ways and picks by image type: +// +// GL_TEXTURE_3D -> VK_IMAGE_TYPE_3D: slices are z, so srcOffset.z/dstOffset.z select them +// and extent.depth counts them; the layer range must stay (0, 1). +// GL_TEXTURE_2D_ARRAY -> VK_IMAGE_TYPE_2D: slices are array layers, so baseArrayLayer selects +// them and layerCount counts them; offset.z stays 0. +// +// A mixed pair is legal (maintenance1, core in Vulkan 1.1) but only when the counts correspond: +// the 3D side's extent.depth has to equal the array side's layerCount. So the four pairs below are +// four DIFFERENT VkImageCopy shapes, not one shape with different arguments, which is why one +// scenario per pair is the coverage that matters here. +// +// Every case also asserts the slices OUTSIDE the copied range still hold their fill. A backend +// that "fixed" the miss by copying the whole image regardless of srcZ/srcDepth would pass a +// slices-landed check and fail this one. +// +// The verification path is an FBO attachment per slice plus glReadPixels, not glGetTexImage: it is +// the readback both backends share, and glFramebufferTextureLayer names an array layer and a 3D +// slice through the same call, so the two texture kinds are read back identically. +// +// DirectGLES is the control - it forwards to the driver's own glCopyImageSubData - so a failure on +// both backends means the scenario is wrong, and a failure on DirectVulkan alone means Magma is. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kWidth = 4; + constexpr int kHeight = 4; + // Six is enough for a copy that starts and ends away from both edges of both endpoints + // while still leaving untouched slices on either side to assert against. + constexpr int kSlices = 6; + + struct Rgba8 { + GLubyte r = 0, g = 0, b = 0, a = 0; + + bool operator==(const Rgba8& other) const { + return r == other.r && g == other.g && b == other.b && a == other.a; + } + }; + + std::string Describe(const Rgba8& color) { + return "(" + std::to_string(color.r) + ", " + std::to_string(color.g) + ", " + std::to_string(color.b) + + ", " + std::to_string(color.a) + ")"; + } + + // Per-slice constants, uniform within a slice. A uniform fill is deliberate: the defect is + // in which SLICE the copy addresses, and a value that also varied within the slice would + // make the assertions depend on the framebuffer row order as well. + Rgba8 SourceColor(int slice) { + return {static_cast(10 + slice * 20), static_cast(40 + slice * 10), + static_cast(200 - slice * 15), 255}; + } + + Rgba8 DestinationFill(int slice) { + return {static_cast(3 + slice), static_cast(250 - slice * 7), + static_cast(120 + slice * 5), 255}; + } + + class CopyImageLayeredScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + if (!CopyImageSubDataUsable()) { + GTEST_SKIP() << "glCopyImageSubData is unavailable on backend " << Gl().BackendName(); + } + } + + void TearDown() override { + if (!Ready()) return; + for (const GLuint texture : m_textures) { + glDeleteTextures(1, &texture); + } + m_textures.clear(); + if (m_fbo != 0) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &m_fbo); + m_fbo = 0; + } + } + + // A trivial 1x1x1 array-to-array copy: it exercises the entry point without depending + // on any of the behaviour under test, so a driver (or a backend function table) that + // simply does not have the call skips instead of failing every case below. + bool CopyImageSubDataUsable() { + GLuint probe[2] = {0, 0}; + glGenTextures(2, probe); + for (const GLuint texture : probe) { + glBindTexture(GL_TEXTURE_2D_ARRAY, texture); + glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA8, 1, 1, 1); + } + glBindTexture(GL_TEXTURE_2D_ARRAY, 0); + while (glGetError() != GL_NO_ERROR) { + } + glCopyImageSubData(probe[0], GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, probe[1], GL_TEXTURE_2D_ARRAY, 0, 0, 0, + 0, 1, 1, 1); + const bool usable = glGetError() == GL_NO_ERROR; + glDeleteTextures(2, probe); + return usable; + } + + // `target` is GL_TEXTURE_2D_ARRAY or GL_TEXTURE_3D; both take glTexStorage3D and + // glTexSubImage3D with the slice on the same axis, which is the whole reason GL can + // copy between them. `levels` > 1 puts a real mip chain behind the level the copy + // names, so the level's own extent - a 3D level's depth included - has to be resolved + // rather than assumed to be the image's. + GLuint MakeTexture(GLenum target, int levels, Rgba8 (*colorForSlice)(int)) { + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + glBindTexture(target, texture); + glTexStorage3D(target, levels, GL_RGBA8, kWidth << (levels - 1), kHeight << (levels - 1), + target == GL_TEXTURE_3D ? (kSlices << (levels - 1)) : kSlices); + glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + // Fill every level, so nothing below can pass by reading a level that was never + // written and happened to hold the expected bytes. + for (int level = 0; level < levels; ++level) { + const int levelWidth = kWidth << (levels - 1 - level); + const int levelHeight = kHeight << (levels - 1 - level); + const int levelSlices = + target == GL_TEXTURE_3D ? (kSlices << (levels - 1 - level)) : kSlices; + for (int slice = 0; slice < levelSlices; ++slice) { + const Rgba8 color = colorForSlice(slice % kSlices); + std::vector texels(static_cast(levelWidth) * levelHeight, color); + glTexSubImage3D(target, level, 0, 0, slice, levelWidth, levelHeight, 1, GL_RGBA, + GL_UNSIGNED_BYTE, texels.data()); + } + } + glBindTexture(target, 0); + return texture; + } + + // One slice of one level, through an FBO attachment. glFramebufferTextureLayer takes an + // array layer and a 3D slice through the same argument, so both targets read back the + // same way. + Rgba8 ReadSlice(GLuint texture, int level, int slice, int width, int height) { + if (m_fbo == 0) { + glGenFramebuffers(1, &m_fbo); + } + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, texture, level, slice); + EXPECT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast(GL_FRAMEBUFFER_COMPLETE)) + << "slice " << slice << " of level " << level << " is not attachable"; + std::vector pixels(static_cast(width) * height, Rgba8{}); + glReadBuffer(GL_COLOR_ATTACHMENT0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + + // The fill is uniform within a slice, so any disagreement between texels is itself + // a failure - reported here rather than silently reduced to pixels[0]. + for (size_t i = 1; i < pixels.size(); ++i) { + EXPECT_TRUE(pixels[i] == pixels[0]) + << "slice " << slice << " of level " << level << " is not uniform: texel 0 is " + << Describe(pixels[0]) << ", texel " << i << " is " << Describe(pixels[i]); + } + return pixels[0]; + } + + // The assertion every case ends with: slices inside [dstZ, dstZ + depth) hold the + // source slice they were fed, and every slice outside it still holds its own fill. + void ExpectCopied(GLuint destination, int level, int width, int height, int sliceCount, int srcZ, + int dstZ, int depth, const char* what) { + for (int slice = 0; slice < sliceCount; ++slice) { + const bool inRange = slice >= dstZ && slice < dstZ + depth; + const Rgba8 expected = + inRange ? SourceColor(srcZ + (slice - dstZ)) : DestinationFill(slice); + const Rgba8 actual = ReadSlice(destination, level, slice, width, height); + EXPECT_TRUE(actual == expected) + << what << ": destination slice " << slice << (inRange ? " (copied)" : " (untouched)") + << " is " << Describe(actual) << ", expected " << Describe(expected); + } + } + + std::vector m_textures; + GLuint m_fbo = 0; + }; + + // 2d_array -> 2d_array. Both endpoints put the slices on the layer axis, so BOTH layer + // counts carry the depth and extent.depth must stay 1. + TEST_F(CopyImageLayeredScenario, ArrayToArrayCopiesEverySlice) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, + kWidth, kHeight, kSlices); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, 0, kWidth, kHeight, kSlices, 0, 0, kSlices, "array->array, all slices"); + } + + // The same pair with the layer ranges offset differently on the two sides: the shape that + // separates "copies more than slice 0" from "copies the RIGHT slices". A backend that read + // the source range but wrote from layer 0 (or vice versa) passes the case above. + TEST_F(CopyImageLayeredScenario, ArrayToArrayHonoursDifferentLayerOffsets) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + constexpr int kSrcZ = 3; + constexpr int kDstZ = 1; + constexpr int kDepth = 2; + glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0, + kDstZ, kWidth, kHeight, kDepth); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, + "array->array, offset layer ranges"); + } + + // 3d -> 3d. Neither endpoint has array layers at all: the depth travels on extent.depth and + // the offsets on srcOffset.z/dstOffset.z, with both layer counts pinned to 1. + TEST_F(CopyImageLayeredScenario, VolumeToVolumeHonoursNonZeroZ) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_3D, 1, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_3D, 1, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + constexpr int kSrcZ = 1; + constexpr int kDstZ = 3; + constexpr int kDepth = 3; + glCopyImageSubData(source, GL_TEXTURE_3D, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, 0, 0, 0, kDstZ, + kWidth, kHeight, kDepth); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "3d->3d, non-zero z"); + } + + // The same pair one mip level down. A 3D level's DEPTH halves with its width and height, so + // this is the only case where the slice count the copy may name is not the image's own - + // the bound a layered endpoint is checked against has to come from the level. + TEST_F(CopyImageLayeredScenario, VolumeToVolumeAtNonZeroMipLevel) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_3D, 2, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_3D, 2, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + constexpr int kLevel = 1; + constexpr int kSrcZ = 2; + constexpr int kDstZ = 0; + constexpr int kDepth = 4; + glCopyImageSubData(source, GL_TEXTURE_3D, kLevel, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, kLevel, 0, 0, + kDstZ, kWidth, kHeight, kDepth); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, kLevel, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, + "3d->3d at mip level 1"); + } + + // 2d_array -> 3d. The mixed shape: the source counts its slices as layers, the destination + // as depth, and Vulkan requires extent.depth to equal the source's layerCount. + TEST_F(CopyImageLayeredScenario, ArrayToVolumeCopiesEverySlice) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_3D, 1, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + constexpr int kSrcZ = 2; + constexpr int kDstZ = 1; + constexpr int kDepth = 4; + glCopyImageSubData(source, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_3D, 0, 0, 0, kDstZ, + kWidth, kHeight, kDepth); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "2d_array->3d"); + } + + // 3d -> 2d_array, the mirror image: the depth now has to reach the DESTINATION's layerCount + // while the source states it as extent.depth from a z offset. + TEST_F(CopyImageLayeredScenario, VolumeToArrayCopiesEverySlice) { + if (!Ready() || IsSkipped()) return; + + const GLuint source = MakeTexture(GL_TEXTURE_3D, 1, SourceColor); + const GLuint destination = MakeTexture(GL_TEXTURE_2D_ARRAY, 1, DestinationFill); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "texture setup failed"; + + constexpr int kSrcZ = 1; + constexpr int kDstZ = 2; + constexpr int kDepth = 4; + glCopyImageSubData(source, GL_TEXTURE_3D, 0, 0, 0, kSrcZ, destination, GL_TEXTURE_2D_ARRAY, 0, 0, 0, kDstZ, + kWidth, kHeight, kDepth); + ASSERT_EQ(glGetError(), static_cast(GL_NO_ERROR)) << "glCopyImageSubData raised an error"; + + ExpectCopied(destination, 0, kWidth, kHeight, kSlices, kSrcZ, kDstZ, kDepth, "3d->2d_array"); + } + + } // namespace +} // namespace MGITest