diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index f33d2f80..1af9b64e 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -7908,9 +7908,14 @@ namespace MobileGL::MG_Backend::DirectGLES { tempFB, GL_READ_FRAMEBUFFER, backendTexId, backendAttachTarget == GL_UNKNOWN_MGL ? target : backendAttachTarget, level, /*withStencil=*/format == GL_DEPTH_STENCIL); - } else if (backendAttachTarget == GL_TEXTURE_3D || backendAttachTarget == GL_TEXTURE_2D_ARRAY) { - // ES cannot attach 3D/array textures through glFramebufferTexture2D; read layer 0. Reads - // of deeper slices are served from the CPU shadow instead (see the shadow-first branch). + } else if (backendAttachTarget == GL_TEXTURE_3D || backendAttachTarget == GL_TEXTURE_2D_ARRAY || + backendAttachTarget == GL_TEXTURE_CUBE_MAP_ARRAY) { + // ES cannot attach 3D/array textures through glFramebufferTexture2D; layer 0 here, and + // the deeper slices one at a time in the per-layer loop below. A CUBE MAP ARRAY is in + // this list for the same reason its layer-faces are addressed as array layers: + // glFramebufferTexture2D has no target token for it, so the 2D attach it used to take + // left the scratch FBO incomplete and every read fell through to the (stale) CPU + // shadow - which is exactly the all-zero result the conformance suite saw. ScratchFBOImpl::EnsureColorAttachmentLayer(tempFB, GL_READ_FRAMEBUFFER, backendTexId, level, 0); } else { ScratchFBOImpl::EnsureColorAttachment2D( @@ -7963,6 +7968,18 @@ namespace MobileGL::MG_Backend::DirectGLES { auto size = textureMipmapObject->GetMipmapTexelSize(MG_Util::ConvertGLEnumToTextureUploadTarget(target), level); + // GL_TEXTURE_1D_ARRAY keeps its LAYERS in the state-side height (that is what + // glTexImage2D(GL_TEXTURE_1D_ARRAY, w, layers) means), while the ES texture behind it is a + // 2D array of height 1 with the layers in depth - GetBackendUploadSize performs exactly + // that swap on the way in. Everything below addresses the ES image, so the same swap has + // to happen here: without it the readback asked layer 0 for a `layers`-row rectangle it + // does not have, and every layer but the first came back undefined (all zeroes on Adreno, + // KHR-GL4x.shader_image_load_store.basic-allTargets-*). + const Bool oneDimensionalArray = textureObject->GetTarget() == TextureTarget::Texture1DArray; + if (oneDimensionalArray) { + size = TextureImpl::GetBackendUploadSize(TextureTarget::Texture1DArray, size); + } + MGLOG_D("GetTexImage: mip level %d size = %dx%d", level, size.x(), size.y()); // Prefer the client-format conversion for every convertible combination: the "native" ES pairs @@ -7976,10 +7993,14 @@ namespace MobileGL::MG_Backend::DirectGLES { TextureImpl::BackendTextureFormatAddsAlpha(textureObject->GetFormat(), textureObject->GetTarget()); // GL_PACK_IMAGE_HEIGHT/GL_PACK_SKIP_IMAGES only apply to 3D/array image // readbacks (cube-map arrays address as arrays); 2D targets must ignore - // them (GL 3.3 section 6.1.4). - const Bool applyPackImageParams = backendAttachTarget == GL_TEXTURE_3D || - backendAttachTarget == GL_TEXTURE_2D_ARRAY || - backendAttachTarget == GL_TEXTURE_CUBE_MAP_ARRAY; + // them (GL 3.3 section 6.1.4). A 1D ARRAY is one of those 2D targets: GL hands it back + // as a single two-dimensional image whose ROWS are the layers, so the layer stride is + // one packed row and the image parameters do not enter into it - even though the ES + // texture underneath is an array and is read one layer at a time. + const Bool applyPackImageParams = !oneDimensionalArray && + (backendAttachTarget == GL_TEXTURE_3D || + backendAttachTarget == GL_TEXTURE_2D_ARRAY || + backendAttachTarget == GL_TEXTURE_CUBE_MAP_ARRAY); const GLsizei sliceCount = std::max(size.z(), 1); const Bool multiSlice = size.z() > 1; // glGetTexImage answers with the STORED texels, and for a packed format whose encoding @@ -8013,7 +8034,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // Attach the layers one at a time instead and read each off the GPU, keeping the shadow // for the formats the FBO cannot represent at all. if (multiSlice && tempFBOComplete && - (backendAttachTarget == GL_TEXTURE_3D || backendAttachTarget == GL_TEXTURE_2D_ARRAY)) { + (backendAttachTarget == GL_TEXTURE_3D || backendAttachTarget == GL_TEXTURE_2D_ARRAY || + backendAttachTarget == GL_TEXTURE_CUBE_MAP_ARRAY)) { // Each slice is packed as its own 2D image, so the per-slice call must not apply // GL_PACK_SKIP_IMAGES / GL_PACK_IMAGE_HEIGHT itself - this walks the destination // over them, using the same layout StoreWideRowsToClient computes. diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 4ac76b5e..60124825 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -91,6 +91,7 @@ add_executable(MobileGLIntegrationTest Scenarios/CopyImageLevelRangeScenario.cpp Scenarios/CopyImageLayeredScenario.cpp Scenarios/LayeredAttachmentBarrierScenario.cpp + Scenarios/LayeredTextureReadbackScenario.cpp Scenarios/AtomicCounterScenario.cpp ) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/LayeredTextureReadbackScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/LayeredTextureReadbackScenario.cpp new file mode 100644 index 00000000..51dd9405 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/LayeredTextureReadbackScenario.cpp @@ -0,0 +1,286 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/LayeredTextureReadbackScenario.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 - READING EVERY LAYER OF A 1D-ARRAY / CUBE-MAP-ARRAY LEVEL BACK. +// +// glGetTexImage has no ES equivalent, so Espryt serves it by attaching the level to a scratch +// READ framebuffer and reading it with glReadPixels. Two of the targets it has to answer for do +// not fit that shape the way the others do, and both came back as zeroes in +// KHR-GL4x.shader_image_load_store.basic-allTargets-* and .non-layered_binding: +// +// * GL_TEXTURE_1D_ARRAY carries its LAYERS in the state-side height - that is what +// glTexImage2D(GL_TEXTURE_1D_ARRAY, w, layers) means - while the ES texture behind it is a 2D +// array of height 1 with the layers in depth. The readback used the state-side shape, so it +// asked layer 0 for a `layers`-row rectangle that layer does not have: row 0 was the only one +// that could be right, and everything past it was whatever reading outside an attachment +// produces. +// * GL_TEXTURE_CUBE_MAP_ARRAY has no glFramebufferTexture2D target token at all, so the 2D +// attach it used to take errored, the scratch FBO stayed incomplete, and every read fell +// through to the CPU shadow - which holds what was UPLOADED, i.e. the seed, not what the +// shader stored. +// +// Both cases store from a compute dispatch (so the only copy of the data is the GPU one and a +// stale shadow cannot pass) and then read the whole level back in one glGetTexImage, checking +// every layer separately so a failure names which one. r32ui throughout: it is a core GLSL ES +// image format, so nothing here can be confused with the missing-format story that +// ImageFormatQualifierScenario covers. +// +// Magma reads these back through its own path and is unaffected by the ES attachment rules, so +// both cases run on both backends and must agree. + +#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 kExtent = 4; + constexpr int kArrayLayers = 3; // enough that "layer 0 only" is visibly wrong + constexpr int kCubeLayerFaces = 12; // two cubes, which is what the conformance case uses + // A value no store writes, so "the store never landed" and "the store wrote the wrong + // thing" cannot be confused - and so a readback served from the stale CPU shadow is + // recognisable on sight. + constexpr GLuint kSeed = 0xFEEDBEEFu; + // Deliberately not 0: the unit has to travel through glUniform1i and be baked into the + // generated ESSL, so a defect there cannot hide behind the default. + constexpr GLint kImageUnit = 1; + + GLuint Expected1DArrayTexel(int x, int layer) { + return 1000u + static_cast(layer) * 100u + static_cast(x); + } + + GLuint ExpectedCubeArrayTexel(int x, int y, int layerFace) { + return 1000u + static_cast(layerFace) * 100u + static_cast(y) * 10u + + static_cast(x); + } + + // One invocation per texel, and the value it writes is a function of its coordinate - so + // a layer read from the wrong slice does not merely differ, it says which slice it came + // from. + const char* k1DArrayStoreSource = R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +layout (r32ui) writeonly uniform uimage1DArray uni_image; + +void main() +{ + uint x = gl_GlobalInvocationID.x; + uint layer = gl_GlobalInvocationID.z; + imageStore(uni_image, ivec2(int(x), int(layer)), uvec4(1000u + layer * 100u + x, 0u, 0u, 0u)); +} +)"; + + const char* kCubeArrayStoreSource = R"(#version 430 core + +layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in; + +layout (r32ui) writeonly uniform uimageCubeArray uni_image; + +void main() +{ + uint x = gl_GlobalInvocationID.x; + uint y = gl_GlobalInvocationID.y; + uint layerFace = gl_GlobalInvocationID.z; + imageStore(uni_image, ivec3(int(x), int(y), int(layerFace)), + uvec4(1000u + layerFace * 100u + y * 10u + x, 0u, 0u, 0u)); +} +)"; + + class LayeredTextureReadbackScenario : public ScenarioTest { + protected: + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + for (GLuint t : m_textures) glDeleteTextures(1, &t); + m_programs.clear(); + m_textures.clear(); + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + for (GLint unit = 0; unit < maxImageUnits; ++unit) { + glBindImageTexture(static_cast(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI); + } + while (glGetError() != GL_NO_ERROR) { + } + } + + bool ImagesAreUsable() const { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + GLint maxComputeImageUniforms = 0; + glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms); + while (glGetError() != GL_NO_ERROR) { + } + return maxImageUnits > kImageUnit && maxComputeImageUniforms >= 1; + } + + GLuint MakeComputeProgram(const char* source) { + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[4096] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute shader did not compile: " << log; + glDeleteShader(shader); + return 0; + } + const GLuint program = glCreateProgram(); + m_programs.push_back(program); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[4096] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + ADD_FAILURE() << "the compute program did not link: " << log; + return 0; + } + return program; + } + + GLuint TrackTexture() { + GLuint texture = 0; + glGenTextures(1, &texture); + m_textures.push_back(texture); + return texture; + } + + // layered = GL_TRUE, i.e. the whole level: that is what makes every layer reachable + // from one dispatch, and it is what glBindImageTextures is specified to pass. + bool DispatchStore(GLuint program, GLuint texture, GLsizei groupsX, GLsizei groupsY, GLsizei groupsZ) { + glBindImageTexture(static_cast(kImageUnit), texture, 0, GL_TRUE, 0, GL_WRITE_ONLY, GL_R32UI); + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << "glBindImageTexture errored with " << GLErrorName(error); + return false; + } + glUseProgram(program); + const GLint location = glGetUniformLocation(program, "uni_image"); + if (location < 0) { + ADD_FAILURE() << "the image uniform was not reflected"; + return false; + } + glUniform1i(location, kImageUnit); + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << "assigning the image unit errored with " << GLErrorName(error); + return false; + } + glDispatchCompute(groupsX, groupsY, groupsZ); + glMemoryBarrier(GL_ALL_BARRIER_BITS); + glUseProgram(0); + if (const GLenum error = FirstGLError()) { + ADD_FAILURE() << "the dispatch errored with " << GLErrorName(error); + return false; + } + return true; + } + + std::vector m_programs; + std::vector m_textures; + }; + + // The 1D-array half. A layer past the first is the whole test: layer 0 lines up with the + // ES image's only row whichever way the axes are read, so a readback that never swapped + // them still got it right and only the deeper layers came back wrong. + TEST_F(LayeredTextureReadbackScenario, GetTexImageReturnsEveryLayerOfA1DArray) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; + + const GLuint program = MakeComputeProgram(k1DArrayStoreSource); + if (program == 0) return; + + const GLuint texture = TrackTexture(); + glBindTexture(GL_TEXTURE_1D_ARRAY, texture); + glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + const std::vector seed(static_cast(kExtent) * kArrayLayers, kSeed); + glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_R32UI, kExtent, kArrayLayers, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, + seed.data()); + ASSERT_EQ(FirstGLError(), 0u) << "creating the R32UI 1D-array texture errored"; + + if (!DispatchStore(program, texture, kExtent, 1, kArrayLayers)) return; + + std::vector texels(seed.size(), 0u); + glBindTexture(GL_TEXTURE_1D_ARRAY, texture); + glGetTexImage(GL_TEXTURE_1D_ARRAY, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data()); + ASSERT_EQ(FirstGLError(), 0u) << "reading the 1D-array level back errored"; + + // GL hands a 1D array back as a plain two-dimensional image whose ROWS are the + // layers, so the destination index is layer * width + x. + for (int layer = 0; layer < kArrayLayers; ++layer) { + for (int x = 0; x < kExtent; ++x) { + const std::size_t index = static_cast(layer) * kExtent + x; + EXPECT_EQ(texels[index], Expected1DArrayTexel(x, layer)) + << "layer " << layer << " texel " << x << " read back " + << (texels[index] == kSeed ? "the seed (the store never reached it, or the readback came " + "from the stale CPU shadow)" + : "an unexpected value"); + } + } + } + + // The cube-map-array half. glFramebufferTexture2D has no token for the target, so the + // scratch FBO used to stay incomplete and every read - including layer 0 - was answered + // from the CPU shadow; the seed is what makes that visible rather than merely wrong. + TEST_F(LayeredTextureReadbackScenario, GetTexImageReturnsEveryLayerFaceOfACubeMapArray) { + if (!Ready()) return; + if (!ImagesAreUsable()) GTEST_SKIP() << "no compute image uniforms"; + + const GLuint program = MakeComputeProgram(kCubeArrayStoreSource); + if (program == 0) return; + + const GLuint texture = TrackTexture(); + glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, texture); + glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + const std::vector seed(static_cast(kExtent) * kExtent * kCubeLayerFaces, kSeed); + glTexImage3D(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_R32UI, kExtent, kExtent, kCubeLayerFaces, 0, GL_RED_INTEGER, + GL_UNSIGNED_INT, seed.data()); + ASSERT_EQ(FirstGLError(), 0u) << "creating the R32UI cube-map-array texture errored"; + + if (!DispatchStore(program, texture, kExtent, kExtent, kCubeLayerFaces)) return; + + std::vector texels(seed.size(), 0u); + glBindTexture(GL_TEXTURE_CUBE_MAP_ARRAY, texture); + glGetTexImage(GL_TEXTURE_CUBE_MAP_ARRAY, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data()); + ASSERT_EQ(FirstGLError(), 0u) << "reading the cube-map-array level back errored"; + + for (int layerFace = 0; layerFace < kCubeLayerFaces; ++layerFace) { + for (int y = 0; y < kExtent; ++y) { + for (int x = 0; x < kExtent; ++x) { + const std::size_t index = + (static_cast(layerFace) * kExtent + y) * kExtent + x; + EXPECT_EQ(texels[index], ExpectedCubeArrayTexel(x, y, layerFace)) + << "layer-face " << layerFace << " texel (" << x << ", " << y << ") read back " + << (texels[index] == kSeed ? "the seed (the store never reached it, or the readback " + "came from the stale CPU shadow)" + : "an unexpected value"); + } + } + } + } + + } // namespace +} // namespace MGITest