Compare commits

...
8 Commits
Author SHA1 Message Date
swung0x48 cc34d34706 [Fix, Test] (MG_Backend/DirectGLES): hand every compiled shader's lifetime to its program - glDeleteShader was never called, so each program build leaked its driver shader objects 2026-08-11 11:11:28 -04:00
swung0x48 757b31592d [Fix, Test] (MG_Backend/DirectVulkan): a multisample resolve that must also change orientation resolves through a pooled scratch image, then blits 2026-08-11 10:49:58 -04:00
swung0x48 dbae4eda10 [Fix, Test] (MG_Backend/DirectVulkan): depth blits to or from the default framebuffer convert their rect out of GL's bottom-origin space, like the colour blit does 2026-08-11 10:24:59 -04:00
swung0x48 fa5ff5d168 [Test] (MG_IntegrationTest): every texture access routine must read the same texel out of a usampler2DArray 2026-08-11 10:21:38 -04:00
swung0x48 994ae372f8 [Fix, Test] (MG_Backend/DirectVulkan): an SSBO block instance array is one binding of N descriptors, so bind every element from its own GL binding point 2026-08-11 10:11:23 -04:00
swung0x48 7ba012adf9 [Test] (MG_IntegrationTest): SSBO runtime-array length across preambles, block arrays and bound ranges 2026-08-11 10:11:23 -04:00
swung0x48 b1fdffd767 [Fix, Test] (MG_Backend/DirectVulkan): read the default framebuffer's depth and stencil back instead of leaving the caller's buffer untouched 2026-08-11 10:11:22 -04:00
swung0x48 18c17ae5ca [Fix, Test] (MG_Backend/DirectVulkan): a blit into the default framebuffer must execute the clear parked before it, not leave it for the readback 2026-08-11 10:08:25 -04:00
11 changed files with 1440 additions and 46 deletions
@@ -4386,11 +4386,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
log.back() = '\0';
MGLOG_E("Shader compilation failed for backend ID %u: %s", backendShaderId, log.data());
m_backendProgramUsable = false;
// Nothing will ever attach this one, so nothing else can free it.
g_GLESFuncs.glDeleteShader(backendShaderId);
continue;
}
MGLOG_D("Attaching shader ID: %u to program %u", backendShaderId, m_backendProgramId);
g_GLESFuncs.glAttachShader(m_backendProgramId, backendShaderId);
// Hand the shader's lifetime to the program, immediately and unconditionally.
//
// glDeleteShader only FLAGS a shader; the driver frees it when it is attached to
// nothing. Flagging it here is what makes the program own it, so deleting the
// program (or the detach loop above, on a relink) is what actually frees it.
// Without this call every program build leaked its shader objects for the process
// lifetime, and a relink leaked them twice - the detach loop above dropped the
// program's reference to shaders nothing had flagged, so they became unreachable
// AND undeletable. The GL swizzle conformance test builds 1,296 programs per case,
// so a handful of cases left tens of thousands of live driver shaders behind and
// the driver started mis-serving them (KHR-GL33/GL40.texture_swizzle.smoke_*).
// Same class of defect as the missing framebuffer/renderbuffer/sampler destructors
// fixed in Wave 1, and the last of that family: this is the one backend GL object
// MobileGL creates without an owning wrapper to destroy it.
g_GLESFuncs.glDeleteShader(backendShaderId);
MGLOG_D("Processed shader source length: %zu", source.length());
}
@@ -2578,6 +2578,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
entry.storageBlockNameByBinding[binding] = uniformName;
entry.storageBlockIndexByBinding[binding] = static_cast<Int>(blockIndex);
// A block INSTANCE array is ONE Vulkan binding carrying `count`
// descriptors, while GL assigns its elements consecutive binding points
// starting at the declared one (GL 4.6 core 7.8). Recording only element 0 -
// which is all this used to do - left the layout claiming descriptorCount 1,
// so every element past the first read a descriptor nobody wrote and
// `b[1].data.length()` answered from an unconstrained buffer instead of its
// own bound range (KHR-GL43.shader_storage_buffer_object.-
// advanced-unsizedArrayLength-*).
entry.bindingDescriptorCounts[binding] = static_cast<Uint16>(std::max<Uint32>(1u, sampler->count));
continue;
}
@@ -677,7 +677,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool UniformManager::ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj,
Uint32 binding,
Uint32 binding, Uint32 element,
VkDescriptorBufferInfo& outBufferInfo) const {
outBufferInfo = {};
MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null");
@@ -688,8 +688,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Int blockIndex = programObj.storageBlockIndexByBinding[binding];
MOBILEGL_ASSERT(blockIndex >= 0, "ResolveStorageBufferDescriptor: no SSBO block mapped to binding %u",
binding);
// A block instance array declares one block whose elements take consecutive GL binding
// points from the declared one (GL 4.6 core 7.8), and the reflection collapses the whole
// array to that one block - so the element index IS the offset from its binding.
const GLuint frontendBinding =
GetShaderStorageBlockBinding(program, static_cast<GLuint>(blockIndex));
GetShaderStorageBlockBinding(program, static_cast<GLuint>(blockIndex)) + element;
const Uint32 bindingPointCount =
static_cast<Uint32>(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::ShaderStorage));
MOBILEGL_ASSERT(frontendBinding < bindingPointCount,
@@ -1416,12 +1419,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
dynamicOffsets.clear();
// Arrayed UBO bindings contribute extra buffer infos and dynamic offsets; reserve for
// the worst case so the pBufferInfo pointers taken below never dangle on reallocation.
// Arrayed SSBO bindings contribute extra buffer infos too (but no dynamic offsets).
Uint32 uboArrayExtra = 0;
for (const auto& arrayEntry : programObj.arrayedUniformBlockIndicesByBinding) {
uboArrayExtra += static_cast<Uint32>(arrayEntry.second.size()) - 1u;
}
Uint32 ssboArrayExtra = 0;
for (const Uint16 count : programObj.bindingDescriptorCounts) {
if (count > 1) ssboArrayExtra += static_cast<Uint32>(count) - 1u;
}
writes.reserve(m_maxBindings);
bufferInfos.reserve(m_maxBindings + uboArrayExtra);
bufferInfos.reserve(m_maxBindings + uboArrayExtra + ssboArrayExtra);
imageInfos.reserve(m_maxBindings);
texelBufferViews.reserve(m_maxBindings);
dynamicOffsets.reserve(programObj.dynamicBindings.size() + uboArrayExtra);
@@ -1493,18 +1501,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
write.pTexelBufferView = &texelBufferViews.back();
writes.push_back(write);
} else if (kind == ProgramFactory::DescriptorBindingKind::StorageBuffer) {
VkDescriptorBufferInfo bufferInfo{};
if (!ResolveStorageBufferDescriptor(program, programObj, binding, bufferInfo)) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u has no valid descriptor",
binding);
return false;
// One write per binding, but `descriptorCount` buffer infos: a GLSL block
// instance array occupies a single binding whose elements each come from their
// own GL binding point.
const Uint32 descriptorCount =
binding < programObj.bindingDescriptorCounts.size()
? std::max<Uint32>(1, programObj.bindingDescriptorCounts[binding])
: 1u;
const SizeT firstBufferInfoIndex = bufferInfos.size();
for (Uint32 element = 0; element < descriptorCount; ++element) {
VkDescriptorBufferInfo bufferInfo{};
if (!ResolveStorageBufferDescriptor(program, programObj, binding, element, bufferInfo)) {
MGLOG_E(
"UniformDescriptorBinder::BindProgramUniformBuffers failed: storage buffer binding %u "
"element %u has no valid descriptor",
binding, element);
return false;
}
bufferInfos.push_back(bufferInfo);
}
bufferInfos.push_back(bufferInfo);
fastRebindKindsEligible = false;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
write.pBufferInfo = &bufferInfos.back();
write.descriptorCount = descriptorCount;
write.pBufferInfo = &bufferInfos[firstBufferInfoIndex];
writes.push_back(write);
} else if (kind == ProgramFactory::DescriptorBindingKind::StorageImage) {
VkDescriptorImageInfo imageInfo{};
@@ -166,9 +166,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
Uint32 frameIndex, VkBufferView& outBufferView);
// `element` indexes a block INSTANCE array's descriptors; it is 0 for every ordinary
// block. Each element resolves through its own GL storage block, and so its own GL
// binding point, buffer and glBindBufferRange window.
Bool ResolveStorageBufferDescriptor(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
VkDescriptorBufferInfo& outBufferInfo) const;
Uint32 element, VkDescriptorBufferInfo& outBufferInfo) const;
Bool ResolveStorageImageDescriptor(VkCommandBuffer commandBuffer,
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj, Uint32 binding,
@@ -3016,6 +3016,7 @@ void main() {
DestroySubmitFencePool();
DestroyDeferredDepthMipmapCleanup();
DestroyMultisampleResolveScratchImage();
DestroyComputePipelines();
// No sweep runs during teardown, but the observers point at this renderer
@@ -7194,6 +7195,158 @@ void main() {
return true;
}
void VulkanRenderer::DestroyMultisampleResolveScratchImage() {
if (m_msResolveScratch.image != VK_NULL_HANDLE) {
vmaDestroyImage(m_allocator, m_msResolveScratch.image, m_msResolveScratch.allocation);
}
m_msResolveScratch = {};
}
Bool VulkanRenderer::AcquireMultisampleResolveScratchImage(VkCommandBuffer commandBuffer, VkFormat format,
VkExtent2D extent) {
if (extent.width == 0 || extent.height == 0 || format == VK_FORMAT_UNDEFINED) {
return false;
}
// Grow-only, and never shrink: these blits repeat at one or two sizes, so the steady state
// is one allocation for the whole process.
if (m_msResolveScratch.image == VK_NULL_HANDLE || m_msResolveScratch.format != format ||
m_msResolveScratch.extent.width < extent.width || m_msResolveScratch.extent.height < extent.height) {
const VkExtent2D grown = {std::max(extent.width, m_msResolveScratch.extent.width),
std::max(extent.height, m_msResolveScratch.extent.height)};
DestroyMultisampleResolveScratchImage();
VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = format;
imageInfo.extent = {grown.width, grown.height, 1};
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VmaAllocationCreateInfo allocationInfo{};
allocationInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE;
allocationInfo.requiredFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
if (vmaCreateImage(m_allocator, &imageInfo, &allocationInfo, &m_msResolveScratch.image,
&m_msResolveScratch.allocation, nullptr) != VK_SUCCESS) {
// Soft failure: the caller keeps the direct resolve, which is what shipped before.
MGLOG_E("AcquireMultisampleResolveScratchImage: vmaCreateImage failed (format=%d %ux%u)",
static_cast<Int>(format), grown.width, grown.height);
m_msResolveScratch = {};
return false;
}
m_msResolveScratch.format = format;
m_msResolveScratch.extent = grown;
m_msResolveScratch.layout = VK_IMAGE_LAYOUT_UNDEFINED;
}
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags srcAccessMask = 0;
GetImageTransitionSourceState(m_msResolveScratch.layout, srcStageMask, srcAccessMask);
if (!VkTextureManager::TransitionImageLayout(commandBuffer, m_msResolveScratch.image,
m_msResolveScratch.layout,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask,
VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT)) {
return false;
}
return true;
}
// The aspects a depth/stencil format actually carries. VkTextureManager keeps its own copy of
// this private, and the swapchain's depth/stencil image has no TextureResource to ask.
static VkImageAspectFlags GetDepthStencilAspectMaskForFormat(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_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;
case VK_FORMAT_S8_UINT:
return VK_IMAGE_ASPECT_STENCIL_BIT;
default:
return VK_IMAGE_ASPECT_NONE;
}
}
// The depth/stencil half of MaterializePendingClearForDefaultFramebuffer. Separate only
// because the image, the aspects and the clear value are all different from the colour one;
// the reason it exists is the same - a readback with no intervening draw has no render pass
// to fold the parked clear into.
Bool VulkanRenderer::MaterializePendingDepthStencilClearForDefaultFramebuffer(
VkCommandBuffer commandBuffer, const MG_State::GLState::FramebufferAttachmentObject& attachment,
const ClearAttachmentPayload& payload) {
const VkImage depthStencilImage = m_swapchainObject.GetDepthStencilImage(m_imageIndexAcquired);
if (depthStencilImage == VK_NULL_HANDLE) {
return false;
}
const VkImageAspectFlags imageAspects =
GetDepthStencilAspectMaskForFormat(m_swapchainObject.GetDepthStencilFormat());
VkImageAspectFlags clearAspects = 0;
if ((payload.mask & GL_DEPTH_BUFFER_BIT) != 0) clearAspects |= (imageAspects & VK_IMAGE_ASPECT_DEPTH_BIT);
if ((payload.mask & GL_STENCIL_BUFFER_BIT) != 0) clearAspects |= (imageAspects & VK_IMAGE_ASPECT_STENCIL_BIT);
if (clearAspects == 0) {
// Nothing this image can express; drop the pending clear rather than leave it to a
// later render pass that would load it against an aspect that does not exist.
m_clearManager->PopPendingClear(attachment);
return true;
}
VkImageLayout currentLayout = m_swapchainObject.GetDepthStencilImageLayout(m_imageIndexAcquired);
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags srcAccessMask = 0;
GetImageTransitionSourceState(currentLayout, srcStageMask, srcAccessMask);
VkImageLayout clearLayout = currentLayout;
if (!VkTextureManager::TransitionImageLayout(commandBuffer, depthStencilImage, clearLayout,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, srcStageMask,
VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask,
VK_ACCESS_TRANSFER_WRITE_BIT, imageAspects)) {
return false;
}
VkClearDepthStencilValue clearValue{};
clearValue.depth = payload.depth;
clearValue.stencil = payload.stencil;
VkImageSubresourceRange range{};
range.aspectMask = clearAspects;
range.baseMipLevel = 0;
range.levelCount = 1;
range.baseArrayLayer = 0;
range.layerCount = 1;
vkCmdClearDepthStencilImage(commandBuffer, depthStencilImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearValue,
1, &range);
VkImageLayout settledLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
VkAccessFlags dstAccessMask = 0;
GetImageTransitionDestinationState(VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, dstStageMask,
dstAccessMask);
if (!VkTextureManager::TransitionImageLayout(commandBuffer, depthStencilImage, settledLayout,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
VK_PIPELINE_STAGE_TRANSFER_BIT, dstStageMask,
VK_ACCESS_TRANSFER_WRITE_BIT, dstAccessMask, imageAspects)) {
return false;
}
m_swapchainObject.SetDepthStencilImageLayout(m_imageIndexAcquired,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
// The image now holds real values, so the next render pass must LOAD them rather than
// treat the attachment as undefined and discard the clear that just executed.
m_swapchainObject.SetDepthStencilContentDefined(m_imageIndexAcquired, true);
m_clearManager->PopPendingClear(attachment);
MGLOG_D("MaterializePendingClearForDefaultFramebuffer: swapchain depth/stencil image %u pending clear "
"materialized (aspects=0x%x)",
m_imageIndexAcquired, static_cast<Uint32>(clearAspects));
return true;
}
// A glClear on the DEFAULT framebuffer is parked as a pending clear and folded into the next
// render pass's loadOp. With no draw in between there is no render pass, so a readback that
// followed such a clear blitted the untouched swapchain image and returned the PREVIOUS
@@ -7217,15 +7370,14 @@ void main() {
if (!m_clearManager->GetPendingClear(attachment, payload)) {
return true;
}
if ((payload.mask & GL_COLOR_BUFFER_BIT) == 0) {
// Depth/stencil on the default framebuffer keeps the loadOp route; the readback
// path for it declines default framebuffers outright (ReadDepthStencilPixels).
return true;
}
MOBILEGL_ASSERT(VkRenderPassManager::GetActiveRenderPass() == nullptr ||
commandBuffer != m_frameContext.GetCurrent().commandBuffer,
"MaterializePendingClearForDefaultFramebuffer requires no active render pass");
if ((payload.mask & GL_COLOR_BUFFER_BIT) == 0) {
return MaterializePendingDepthStencilClearForDefaultFramebuffer(commandBuffer, attachment, payload);
}
const VkImage swapchainImage = m_swapchainObject.GetImage(m_imageIndexAcquired);
if (swapchainImage == VK_NULL_HANDLE) {
return false;
@@ -7570,12 +7722,21 @@ void main() {
}
}
if (!drawIsDefaultFbo) {
const auto destAttachmentType =
ResolveFramebufferCopyAttachmentType(*drawFbo, false, dstBinding.aspectMask);
if (drawIsDefaultFbo) {
// Same ordering rule for the default framebuffer's depth/stencil - see the
// colour twin below.
const Bool dstClearReady = MaterializePendingClearForDefaultFramebuffer(
frame.commandBuffer, *drawFbo, destAttachmentType);
MOBILEGL_ASSERT(dstClearReady,
"BlitFramebuffer: failed to materialize the default framebuffer's pending "
"depth/stencil clear");
} else {
// A clear queued for the destination predates this blit in API order;
// execute it now, or its deferred materialization would later stomp the
// copied contents (MC 26.3 OIT clears cloud_depth, then blits the main
// depth into it - the stale loadOp=CLEAR erased the copy).
const auto destAttachmentType = ResolveFramebufferCopyAttachmentType(*drawFbo, false, dstBinding.aspectMask);
const auto& destAttachment = drawFbo->GetAttachment(destAttachmentType);
if (auto destTexture = destAttachment.GetTexture(); destTexture != nullptr) {
const Bool dstClearReady = MaterializePendingClearForTexture(frame.commandBuffer, *destTexture);
@@ -7668,7 +7829,15 @@ void main() {
MOBILEGL_ASSERT(ok, "%s: failed to transition depth destination image", __func__);
}
if (depthBlitScales) {
// The default framebuffer is stored display-side-up, so a rect aimed at it (or read
// from it) has to be converted out of GL's bottom-origin space - the same conversion
// the colour blit below applies. vkCmdCopyImage cannot express it (it has no second
// offset to invert), so a default-framebuffer side forces the vkCmdBlitImage form even
// at equal size. Without this a scissored depth blit into the default framebuffer
// wrote the MIRRORED band: KHR-GL*.framebuffer_blit.scissor_blit clips to the lower
// left quadrant, and the depth landed in the upper one.
const Bool depthBlitNeedsOrientation = readIsDefaultFbo || drawIsDefaultFbo;
if (depthBlitScales || depthBlitNeedsOrientation) {
// vkCmdCopyImage cannot resize; NEAREST is the only filter Vulkan allows for a
// depth/stencil blit anyway, and the GL front end already rejects the others.
VkImageBlit blitRegion{};
@@ -7684,6 +7853,14 @@ void main() {
blitRegion.dstSubresource.layerCount = dstBinding.layerCount;
blitRegion.dstOffsets[0] = {dstX0, dstY0, 0};
blitRegion.dstOffsets[1] = {dstX1, dstY1, 1};
if (readIsDefaultFbo) {
ApplyNativeBlitDefaultFramebufferSourceTransform(m_swapchainObject.GetPreTransform(), srcBinding,
blitRegion);
}
if (drawIsDefaultFbo) {
ApplyNativeBlitDefaultFramebufferTransform(m_swapchainObject.GetPreTransform(), dstBinding,
blitRegion);
}
vkCmdBlitImage(frame.commandBuffer,
srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
@@ -7780,7 +7957,19 @@ void main() {
}
}
if (!drawIsDefaultFbo) {
if (drawIsDefaultFbo) {
// The default framebuffer needs the same ordering, and needed it before anything
// consumed its parked clear: Minecraft clears the default framebuffer, renders the
// world into its own framebuffer and BLITS the result out, so nothing between the
// clear and the blit ever opens a render pass on the default framebuffer to fold the
// clear in as a loadOp. The clear therefore stayed pending across the whole frame,
// and the first path that did materialize it - the readback - executed it AFTER the
// blit and handed back a blank frame (every DirectVulkan retrace, ssim 0.000005).
const Bool dstClearReady = MaterializePendingClearForDefaultFramebuffer(
frame.commandBuffer, *drawFbo, drawFbo->GetDrawBuffers()[0]);
MOBILEGL_ASSERT(dstClearReady,
"BlitFramebuffer: failed to materialize the default framebuffer's pending clear");
} else {
// A clear queued for the destination predates this blit in API order; execute
// it now, or its deferred materialization would later stomp the blitted color.
const auto& destAttachment = drawFbo->GetAttachment(drawFbo->GetDrawBuffers()[0]);
@@ -7878,23 +8067,78 @@ void main() {
}
if (srcBinding.sampleCount != VK_SAMPLE_COUNT_1_BIT && dstBinding.sampleCount == VK_SAMPLE_COUNT_1_BIT) {
// NOTE: vkCmdResolveImage cannot flip, and this region is still built from the raw GL
// offsets. A multisample-resolve blit whose source or destination is the default
// framebuffer therefore keeps the pre-fix behaviour; it needs a resolve-then-blit
// (or blit-then-resolve) split, which is its own change.
// GL multisample resolve blits are 1:1 by spec; vkCmdBlitImage cannot read a
// multisampled source.
// multisampled source, so the samples have to come down through vkCmdResolveImage.
const Uint32 resolveWidth = static_cast<Uint32>(std::abs(srcX1 - srcX0));
const Uint32 resolveHeight = static_cast<Uint32>(std::abs(srcY1 - srcY0));
// vkCmdResolveImage takes ONE offset per side, so it cannot express the axis inversion
// that a default-framebuffer rect needs - it would land the mirrored band. When the
// transforms above actually moved the region, split the operation: resolve into a
// single-sample scratch image at raw offsets, then blit THAT into the destination with
// the (already transformed) region, which vkCmdBlitImage can invert.
const Bool regionWasTransformed =
(readIsDefaultFbo || drawIsDefaultFbo) &&
(blitRegion.srcOffsets[0].x != srcX0 || blitRegion.srcOffsets[0].y != srcY0 ||
blitRegion.srcOffsets[1].x != srcX1 || blitRegion.srcOffsets[1].y != srcY1 ||
blitRegion.dstOffsets[0].x != dstX0 || blitRegion.dstOffsets[0].y != dstY0 ||
blitRegion.dstOffsets[1].x != dstX1 || blitRegion.dstOffsets[1].y != dstY1);
const Bool useScratchResolve =
regionWasTransformed && resolveWidth > 0 && resolveHeight > 0 &&
AcquireMultisampleResolveScratchImage(frame.commandBuffer, srcBinding.format,
{resolveWidth, resolveHeight});
VkImageResolve resolveRegion{};
resolveRegion.srcSubresource = blitRegion.srcSubresource;
resolveRegion.srcOffset = {std::min(srcX0, srcX1), std::min(srcY0, srcY1), 0};
resolveRegion.dstSubresource = blitRegion.dstSubresource;
resolveRegion.dstOffset = {std::min(dstX0, dstX1), std::min(dstY0, dstY1), 0};
resolveRegion.extent = {static_cast<Uint32>(std::abs(srcX1 - srcX0)),
static_cast<Uint32>(std::abs(srcY1 - srcY0)), 1};
vkCmdResolveImage(frame.commandBuffer,
srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &resolveRegion);
resolveRegion.extent = {resolveWidth, resolveHeight, 1};
if (useScratchResolve) {
// The scratch copy is a plain single-layer colour image, and the resolve reads the
// SOURCE band the (possibly inverted) transformed region names - taking its min so
// an inverted pair still describes the same band.
resolveRegion.srcOffset = {std::min(blitRegion.srcOffsets[0].x, blitRegion.srcOffsets[1].x),
std::min(blitRegion.srcOffsets[0].y, blitRegion.srcOffsets[1].y), 0};
resolveRegion.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
resolveRegion.dstSubresource.mipLevel = 0;
resolveRegion.dstSubresource.baseArrayLayer = 0;
resolveRegion.dstSubresource.layerCount = 1;
resolveRegion.dstOffset = {0, 0, 0};
vkCmdResolveImage(frame.commandBuffer,
srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
m_msResolveScratch.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &resolveRegion);
VkImageLayout scratchLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
const Bool scratchReady = VkTextureManager::TransitionImageLayout(
frame.commandBuffer, m_msResolveScratch.image, scratchLayout,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
VK_IMAGE_ASPECT_COLOR_BIT);
MOBILEGL_ASSERT(scratchReady, "%s: failed to transition the resolve scratch image", __func__);
m_msResolveScratch.layout = scratchLayout;
// Second leg: the scratch image holds the resolved band at its own origin, so the
// source side of the region becomes the whole scratch rect and only the
// destination keeps the transform.
VkImageBlit scratchBlit = blitRegion;
scratchBlit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
scratchBlit.srcSubresource.mipLevel = 0;
scratchBlit.srcSubresource.baseArrayLayer = 0;
scratchBlit.srcSubresource.layerCount = 1;
scratchBlit.srcOffsets[0] = {0, 0, 0};
scratchBlit.srcOffsets[1] = {static_cast<Int32>(resolveWidth), static_cast<Int32>(resolveHeight), 1};
vkCmdBlitImage(frame.commandBuffer,
m_msResolveScratch.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &scratchBlit, filter == GL_LINEAR ? VK_FILTER_LINEAR : VK_FILTER_NEAREST);
} else {
resolveRegion.srcOffset = {std::min(srcX0, srcX1), std::min(srcY0, srcY1), 0};
resolveRegion.dstOffset = {std::min(dstX0, dstX1), std::min(dstY0, dstY1), 0};
vkCmdResolveImage(frame.commandBuffer,
srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
dstBinding.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &resolveRegion);
}
} else {
vkCmdBlitImage(frame.commandBuffer,
srcBinding.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
@@ -8729,10 +8973,6 @@ void main() {
void VulkanRenderer::ReadDepthStencilPixels(MG_State::GLState::FramebufferObject& readFbo, GLint x, GLint y,
GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels) {
if (readFbo.IsDefaultFramebuffer()) {
MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: default framebuffer readback is unsupported");
return;
}
if (width <= 0 || height <= 0) {
return;
}
@@ -8743,10 +8983,13 @@ void main() {
// framebuffers lacking either, so resolving via the depth attachment is enough.
const auto attachmentType = wantDepth ? MobileGL::FramebufferAttachmentType::Depth
: MobileGL::FramebufferAttachmentType::Stencil;
const auto& attachment = readFbo.GetAttachment(attachmentType);
if (!attachment.IsValid() || attachment.IsEmpty()) {
MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: no depth/stencil attachment image");
return;
const Bool readIsDefaultFbo = readFbo.IsDefaultFramebuffer();
if (!readIsDefaultFbo) {
const auto& attachment = readFbo.GetAttachment(attachmentType);
if (!attachment.IsValid() || attachment.IsEmpty()) {
MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: no depth/stencil attachment image");
return;
}
}
auto& frame = m_frameContext.GetCurrent();
@@ -8757,6 +9000,47 @@ void main() {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
// The default framebuffer's depth/stencil lives in the swapchain, not in an
// attachment object: its placeholder ITextureObject describes the format but backs no
// image, so the branches below would have synced (and read back) an unrelated one.
// Declining outright is what made every glReadPixels(GL_DEPTH_COMPONENT/
// GL_STENCIL_INDEX) of the default framebuffer leave the caller's buffer untouched -
// the whole KHR-GL*.framebuffer_blit family checks exactly that before it blits.
if (readIsDefaultFbo) {
const VkImage swapchainDepthImage = m_swapchainObject.GetDepthStencilImage(m_imageIndexAcquired);
if (swapchainDepthImage == VK_NULL_HANDLE) {
MGLOG_E("DirectVulkan::ReadDepthStencilPixels skipped: the default framebuffer has no "
"depth/stencil image");
return;
}
// Per aspect, because the default framebuffer carries a SEPARATE placeholder
// attachment for depth and for stencil (MG_Impl/Init.cpp) and each parks its own
// pending clear; materializing only one would read the other back un-cleared.
if (wantDepth) {
const Bool clearReady = MaterializePendingClearForDefaultFramebuffer(
frame.commandBuffer, readFbo, MobileGL::FramebufferAttachmentType::Depth);
MOBILEGL_ASSERT(clearReady,
"ReadDepthStencilPixels: failed to materialize the default framebuffer's pending "
"depth clear");
}
if (wantStencil) {
const Bool clearReady = MaterializePendingClearForDefaultFramebuffer(
frame.commandBuffer, readFbo, MobileGL::FramebufferAttachmentType::Stencil);
MOBILEGL_ASSERT(clearReady,
"ReadDepthStencilPixels: failed to materialize the default framebuffer's pending "
"stencil clear");
}
const VkFormat swapchainDepthFormat = m_swapchainObject.GetDepthStencilFormat();
VkImageLayout trackedLayout = m_swapchainObject.GetDepthStencilImageLayout(m_imageIndexAcquired);
ReadDepthStencilImageToClient(swapchainDepthImage, swapchainDepthFormat, &trackedLayout,
GetDepthStencilAspectMaskForFormat(swapchainDepthFormat), 0, 0, x, y,
width, height, format, type, pixels,
/*defaultFramebufferOrientation=*/true);
m_swapchainObject.SetDepthStencilImageLayout(m_imageIndexAcquired, trackedLayout);
return;
}
const auto& attachment = readFbo.GetAttachment(attachmentType);
VkImage image = VK_NULL_HANDLE;
VkFormat vkFormat = VK_FORMAT_UNDEFINED;
VkImageLayout* trackedLayout = nullptr;
@@ -8807,7 +9091,8 @@ void main() {
void VulkanRenderer::ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout,
VkImageAspectFlags imageAspect, Uint32 mipLevel,
Uint32 baseArrayLayer, GLint x, GLint y, GLsizei width,
GLsizei height, GLenum format, GLenum type, void* pixels) {
GLsizei height, GLenum format, GLenum type, void* pixels,
Bool defaultFramebufferOrientation) {
const Bool wantDepth = format != GL_STENCIL_INDEX;
const Bool wantStencil = format != GL_DEPTH_COMPONENT;
auto& frame = m_frameContext.GetCurrent();
@@ -8872,6 +9157,21 @@ void main() {
VK_PIPELINE_STAGE_TRANSFER_BIT, srcAccessMask, VK_ACCESS_TRANSFER_READ_BIT, imageAspect, mipLevel, 1);
MOBILEGL_ASSERT(ok, "%s: failed to transition depth-stencil source image", __func__);
// The swapchain's depth/stencil image is stored display-side-up like its colour twin, so
// the GL rect has to be mapped into that space before the copy and the copied rows
// re-oriented afterwards - the same two halves the colour ReadPixels path applies.
Int32 copyOffsetX = x;
Int32 copyOffsetY = y;
if (defaultFramebufferOrientation) {
const VkExtent2D defaultFboExtent = m_swapchainObject.GetExtent();
const DefaultFramebufferRectMapping mapping =
GetDefaultFramebufferRectMapping(m_swapchainObject.GetPreTransform());
copyOffsetX = MapDefaultFramebufferRectAxis(x, width, static_cast<Int>(defaultFboExtent.width),
mapping.mirrorX);
copyOffsetY = MapDefaultFramebufferRectAxis(y, height, static_cast<Int>(defaultFboExtent.height),
mapping.flipY);
}
VkBufferImageCopy regions[2]{};
Uint32 regionCount = 0;
if (wantDepth) {
@@ -8881,7 +9181,7 @@ void main() {
region.imageSubresource.mipLevel = mipLevel;
region.imageSubresource.baseArrayLayer = baseArrayLayer;
region.imageSubresource.layerCount = 1;
region.imageOffset = {x, y, 0};
region.imageOffset = {copyOffsetX, copyOffsetY, 0};
region.imageExtent = {static_cast<Uint32>(width), static_cast<Uint32>(height), 1};
}
if (wantStencil) {
@@ -8891,7 +9191,7 @@ void main() {
region.imageSubresource.mipLevel = mipLevel;
region.imageSubresource.baseArrayLayer = baseArrayLayer;
region.imageSubresource.layerCount = 1;
region.imageOffset = {x, y, 0};
region.imageOffset = {copyOffsetX, copyOffsetY, 0};
region.imageExtent = {static_cast<Uint32>(width), static_cast<Uint32>(height), 1};
}
vkCmdCopyImageToBuffer(frame.commandBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.GetHandle(),
@@ -8916,6 +9216,38 @@ void main() {
const Uint8* depthSrc = mapped;
const Uint8* stencilSrc = mapped + stencilOffset;
// Re-orient the copied band per aspect, before any repacking reads it: the depth and
// stencil aspects were copied into their own tightly packed sub-buffers, so each is a
// plain width x height image of its own texel size.
Vector<Uint8> remappedDepth;
Vector<Uint8> remappedStencil;
if (defaultFramebufferOrientation) {
const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform();
Bool remapped = true;
if (wantDepth && depthCopyBytes > 0) {
remappedDepth.resize(pixelCount * depthCopyBytes);
remapped = RemapDefaultFboReadbackToGLOrientation(depthSrc, static_cast<Uint32>(width),
static_cast<Uint32>(height), preTransform,
depthCopyBytes, remappedDepth.data());
}
if (remapped && wantStencil) {
remappedStencil.resize(pixelCount);
remapped = RemapDefaultFboReadbackToGLOrientation(stencilSrc, static_cast<Uint32>(width),
static_cast<Uint32>(height), preTransform, 1,
remappedStencil.data());
}
if (remapped) {
if (!remappedDepth.empty()) depthSrc = remappedDepth.data();
if (!remappedStencil.empty()) stencilSrc = remappedStencil.data();
} else {
// Only a quarter-turn pre-transform reaches this, and nothing in this renderer
// models one. MGLOG_I because the INFO builds are the ones that run conformance.
MGLOG_I("DirectVulkan::ReadDepthStencilPixels: default-FBO remap declined (w=%d h=%d "
"preTransform=%d); falling back to raw readback",
width, height, static_cast<Int>(preTransform));
}
}
const auto depthValueAt = [&](SizeT i) -> Float {
switch (vkFormat) {
case VK_FORMAT_D16_UNORM: {
@@ -211,10 +211,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
GLsizei height, GLenum format, GLenum type, void* pixels);
// Copy-and-repack core shared by depth-stencil ReadPixels and GetTexImage;
// expects command recording to be active and any render pass already ended.
//
// `defaultFramebufferOrientation` is set only when the source is the swapchain's
// depth/stencil image, which this renderer stores display-side-up: the copy rect then
// has to be mapped out of GL's bottom-origin space and the copied rows re-oriented on
// the way back, exactly as the colour ReadPixels path does.
void ReadDepthStencilImageToClient(VkImage image, VkFormat vkFormat, VkImageLayout* trackedLayout,
VkImageAspectFlags imageAspect, Uint32 mipLevel, Uint32 baseArrayLayer,
GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type,
void* pixels);
void* pixels, Bool defaultFramebufferOrientation = false);
// Same-extent depth blit between images of different depth formats: host
// round-trip with a per-texel re-encode (see BlitNamedFramebuffer).
Bool BlitDepthAcrossFormats(FrameContext::FrameData& frame, VkImage srcImage, VkFormat srcFormat,
@@ -363,6 +368,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 samplerBinding = 0;
};
// A single-sample staging image for multisample-resolve blits that also have to change
// orientation. vkCmdResolveImage cannot flip (it takes one offset per side, not the
// invertible pair vkCmdBlitImage takes), so a resolve into or out of the default
// framebuffer used to land the mirrored band. Resolving here first and then blitting from
// here separates the two operations, and each one then does only what it can express.
//
// Pooled rather than created per blit: the CTS runs hundreds of these back to back, and
// create-destroy per call would both cost allocations and, worse, need per-call deferred
// destruction to outlive the recording. It grows to the largest extent asked for and is
// reused; format changes recreate it.
struct MultisampleResolveScratchImage {
VkImage image = VK_NULL_HANDLE;
VmaAllocation allocation = VK_NULL_HANDLE;
VkFormat format = VK_FORMAT_UNDEFINED;
VkExtent2D extent = {0, 0};
VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED;
};
MultisampleResolveScratchImage m_msResolveScratch;
// Returns a scratch image at least `extent` in size with exactly `format`, transitioned to
// TRANSFER_DST and ready to be resolved into. Null image on failure (the caller then falls
// back to the direct resolve).
Bool AcquireMultisampleResolveScratchImage(VkCommandBuffer commandBuffer, VkFormat format,
VkExtent2D extent);
void DestroyMultisampleResolveScratchImage();
struct DeferredDepthMipmapCleanup {
Vector<VkImageView> imageViews;
Vector<VkFramebuffer> framebuffers;
@@ -1125,6 +1155,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool MaterializePendingClearForDefaultFramebuffer(VkCommandBuffer commandBuffer,
MG_State::GLState::FramebufferObject& fbo,
FramebufferAttachmentType attachmentType);
// Its depth/stencil half: a different image (the swapchain's depth/stencil twin), a
// different clear command and per-aspect masking.
Bool MaterializePendingDepthStencilClearForDefaultFramebuffer(
VkCommandBuffer commandBuffer, const MG_State::GLState::FramebufferAttachmentObject& attachment,
const ClearAttachmentPayload& payload);
VkPipeline GetOrCreateBlitPipeline(const RenderPassEntry& renderPassEntry);
Bool GenerateDepthMipmapWithShader(FrameContext::FrameData& frame,
MG_State::GLState::ITextureObject& texture,
@@ -58,6 +58,9 @@ add_executable(MobileGLIntegrationTest
Scenarios/PixelStoreSweepScenario.cpp
Scenarios/FragCoordOriginScenario.cpp
Scenarios/ClearThenReadPixelsScenario.cpp
Scenarios/DepthStencilReadbackScenario.cpp
Scenarios/SsboArrayLengthScenario.cpp
Scenarios/SwizzleAccessRoutineScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -180,4 +180,156 @@ void main() { o_color = vec4(0.1, 0.2, 0.3, 1.0); }
gl.EndFrame();
glDeleteProgram(program);
}
// The other half of the same rule, and the one the first version of this fix got wrong: a
// parked clear must be executed BEFORE whatever writes the framebuffer next, not whenever the
// readback happens to notice it. Minecraft clears the default framebuffer, renders the world
// into its own framebuffer and blits the result out; nothing in between opens a render pass on
// the default framebuffer, so the clear stays parked across the whole frame. Materializing it
// at readback time therefore ran it AFTER the blit and returned a blank frame - which is what
// took every DirectVulkan retrace to ssim 0.000005.
TEST_F(ClearThenReadPixelsScenario, ABlitIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
// Paint a source framebuffer, exactly as a game renders its world off-screen.
ColorFbo source = MakeColorFbo(width, height);
ASSERT_NE(source.fbo, 0u);
BindFbo(source);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawFullViewportQuad(program);
// Clear the DEFAULT framebuffer, then blit the source over it. The clear is white so a
// frame that lost the blit is unmistakable, and the blit's colour is fshSimple's.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, source.fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const Image blitted = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
const Rgba8 centre = blitted.At(width / 2, height / 2);
EXPECT_NEAR(centre.r, 26, 2) << "the blit into the default framebuffer did not survive the clear that "
"preceded it; read back rgba(" << static_cast<int>(centre.r) << ", "
<< static_cast<int>(centre.g) << ", " << static_cast<int>(centre.b) << ", "
<< static_cast<int>(centre.a) << ")";
EXPECT_NEAR(centre.g, 51, 2);
EXPECT_NEAR(centre.b, 77, 2);
DestroyColorFbo(source);
gl.EndFrame();
glDeleteProgram(program);
}
// A MULTISAMPLE-RESOLVE blit into the default framebuffer has to change orientation like any
// other, but vkCmdResolveImage takes one offset per side and cannot invert an axis, so it used
// to land the mirrored band. The renderer now resolves into a single-sample scratch image and
// blits from there. The source is painted in two horizontal bands so the mirror is visible;
// a full-extent uniform blit is a fixed point of the flip and would prove nothing.
TEST_F(ClearThenReadPixelsScenario, AMultisampleResolveBlitIntoTheDefaultFramebufferKeepsItsOrientation) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(height, 8);
GLint maxSamples = 0;
glGetIntegerv(GL_MAX_SAMPLES, &maxSamples);
if (maxSamples < 2) {
GTEST_SKIP() << "GL_MAX_SAMPLES is " << maxSamples << "; this needs a multisample renderbuffer";
}
GLuint fbo = 0, rbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 2, GL_RGBA8, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
glDeleteRenderbuffers(1, &rbo);
glDeleteFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
GTEST_SKIP() << "no complete 2x multisample RGBA8 renderbuffer on this driver";
}
glViewport(0, 0, width, height);
// Bottom half red, top half blue - via scissored clears, so no shader is involved.
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width, height / 2);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
glScissor(0, height / 2, width, height - height / 2);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
glDisable(GL_SCISSOR_TEST);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const Image resolved = ReadPixels(width, height);
EXPECT_EQ(FirstGLError(), 0u);
const Rgba8 bottom = resolved.At(width / 2, height / 4);
const Rgba8 top = resolved.At(width / 2, height - 1 - height / 4);
EXPECT_GT(bottom.r, 200) << "the bottom band should be red after the resolve, got rgba("
<< static_cast<int>(bottom.r) << ", " << static_cast<int>(bottom.g) << ", "
<< static_cast<int>(bottom.b) << ") - blue there means the resolve landed "
<< "in the mirrored band";
EXPECT_LT(bottom.b, 60);
EXPECT_GT(top.b, 200) << "the top band should be blue after the resolve, got rgba("
<< static_cast<int>(top.r) << ", " << static_cast<int>(top.g) << ", "
<< static_cast<int>(top.b) << ")";
EXPECT_LT(top.r, 60);
glDeleteRenderbuffers(1, &rbo);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
// The same ordering claim for the path that DOES open a render pass. It passes today (the
// render pass folds the clear into its loadOp and pops it), and it is here so a future change
// to the pending-clear lifecycle cannot quietly reverse clear and draw.
TEST_F(ClearThenReadPixelsScenario, ADrawIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
DrawFullViewportQuad(program);
EXPECT_EQ(FirstGLError(), 0u);
const Image painted = ReadPixels(width, height);
const Rgba8 centre = painted.At(width / 2, height / 2);
EXPECT_NEAR(centre.r, 26, 2) << "the draw did not survive the clear that preceded it";
EXPECT_NEAR(centre.g, 51, 2);
EXPECT_NEAR(centre.b, 77, 2);
gl.EndFrame();
glDeleteProgram(program);
}
} // namespace MGITest
@@ -0,0 +1,297 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DepthStencilReadbackScenario.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 - glReadPixels OF DEPTH AND STENCIL FROM THE DEFAULT FRAMEBUFFER.
//
// DirectVulkan's depth/stencil readback used to decline the default framebuffer outright
// (`ReadDepthStencilPixels` returned at its first line) because that framebuffer's depth and
// stencil "attachments" are placeholder texture objects backing no image - the real one is the
// swapchain's depth/stencil twin. Declining meant the call raised no GL error and wrote NOTHING,
// so the caller kept whatever its buffer already held.
//
// That silence is what the framebuffer_blit family trips over. Every one of its cases begins by
// clearing the default framebuffer's depth and stencil and reading them straight back as a
// sanity check, into a local pre-initialised to 0.2 (depth) and 50 (stencil); an untouched
// buffer therefore reports "expected DEPTH[0.25] but got DEPTH[0.2]" and "expected STENCIL[1] but
// got STENCIL[50]" - the exact strings in the 15 Magma failures - long before any blit happens.
// A test that only checked "no GL error" would pass against the broken path, so every case here
// poisons its destination with a value the correct answer cannot be.
//
// The orientation case is the second half. This renderer stores the default framebuffer
// display-side-up and converts GL rects on their way in, so the depth copy needs the same rect
// mapping and row re-ordering the colour readback got in the M-1 fix; without them a
// vertically-varying depth buffer reads back mirrored, which no full-extent uniform-value test
// can see.
//
// Depth/stencil readback through a USER framebuffer already worked and is asserted here too, as
// the built-in control: it shares ReadDepthStencilImageToClient with the default-framebuffer
// path, so it is what says a failure is about the default framebuffer specifically.
#include <cmath>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Values no correct read can produce, so "the backend wrote nothing" fails loudly instead
// of passing on whatever happened to be in the variable. These are the CTS's own poison
// values, which is why its logs report exactly them.
constexpr float kDepthPoison = 0.2f;
constexpr int kStencilPoison = 50;
class DepthStencilReadbackScenario : public ScenarioTest {
protected:
// DirectGLES reads depth and stencil back through the ES driver, which has no
// guaranteed path for either (GL_NV_read_depth / GL_NV_read_stencil are optional and
// absent on both the Adreno device and Mesa's ES). That gap is tracked separately as
// the packed_depth_stencil cluster and needs a shader-sampling emulation, not this
// change; asserting it here would only pin a known-missing feature.
bool BackendReadsDepthStencil() const { return Gl().BackendName() == "DirectVulkan"; }
float ReadDepthAt(int x, int y) const {
float depth = kDepthPoison;
glReadPixels(x, y, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth);
return depth;
}
int ReadStencilAt(int x, int y) const {
int stencil = kStencilPoison;
glReadPixels(x, y, 1, 1, GL_STENCIL_INDEX, GL_INT, &stencil);
return stencil;
}
};
// A depth buffer whose value depends on the row: bottom half `bottom`, top half `top`.
// Built with a scissored clear rather than a draw so the test stays independent of
// depth-test and shader behaviour.
void ClearDepthInBands(int width, int height, float bottom, float top) {
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width, height / 2);
glClearDepth(bottom);
glClear(GL_DEPTH_BUFFER_BIT);
glScissor(0, height / 2, width, height - height / 2);
glClearDepth(top);
glClear(GL_DEPTH_BUFFER_BIT);
glDisable(GL_SCISSOR_TEST);
}
} // namespace
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName()
<< " has no depth readback path (ES lacks GL_NV_read_depth); see the packed_depth_stencil "
"cluster";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glClearDepth(0.25);
glClear(GL_DEPTH_BUFFER_BIT);
const float centre = ReadDepthAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(centre, 0.25f, 1.0f / 4096.0f)
<< "glReadPixels(GL_DEPTH_COMPONENT) of the default framebuffer returned " << centre
<< (std::fabs(centre - kDepthPoison) < 1e-6f ? " - the destination was never written at all" : "");
gl.EndFrame();
}
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferStencilClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName()
<< " has no stencil readback path (ES lacks GL_NV_read_stencil); see the "
"packed_depth_stencil cluster";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glStencilMask(0xFFu);
glClearStencil(3);
glClear(GL_STENCIL_BUFFER_BIT);
const int centre = ReadStencilAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre, 3) << "glReadPixels(GL_STENCIL_INDEX) of the default framebuffer returned " << centre
<< (centre == kStencilPoison ? " - the destination was never written at all" : "");
gl.EndFrame();
}
// The orientation half: a depth buffer that varies with the row must read back in GL's
// bottom-up order. A full-extent uniform clear is a fixed point of the flip, so only a banded
// buffer can tell the two apart.
TEST_F(DepthStencilReadbackScenario, DefaultFramebufferDepthReadbackKeepsTheGLRowOrder) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(height, 8);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDepthMask(GL_TRUE);
ClearDepthInBands(width, height, /*bottom=*/0.25f, /*top=*/0.75f);
EXPECT_EQ(FirstGLError(), 0u);
const float bottom = ReadDepthAt(width / 2, height / 4);
const float top = ReadDepthAt(width / 2, height - 1 - height / 4);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(bottom, 0.25f, 1.0f / 4096.0f)
<< "GL row " << (height / 4) << " is in the bottom band and was cleared to 0.25, but read back " << bottom
<< " (0.75 there means the readback is upside down)";
EXPECT_NEAR(top, 0.75f, 1.0f / 4096.0f)
<< "GL row " << (height - 1 - height / 4) << " is in the top band and was cleared to 0.75, but read back "
<< top << " (0.25 there means the readback is upside down)";
gl.EndFrame();
}
// A depth blit INTO the default framebuffer has to convert its rect out of GL's bottom-origin
// space, exactly as the colour blit does. The colour path had that conversion and the
// depth path did not, so a scissored depth blit landed in the mirrored band - which is the
// whole of KHR-GL*.framebuffer_blit.scissor_blit once the readback above works well enough to
// see it (before that the test died on the poison values and never reached the blit).
TEST_F(DepthStencilReadbackScenario, AScissoredDepthBlitIntoTheDefaultFramebufferLandsInTheScissorBox) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
ASSERT_GE(width, 8);
ASSERT_GE(height, 8);
// Source: a user framebuffer whose depth is uniformly 0.75.
GLuint fbo = 0, colorTex = 0, depthTex = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);
glGenTextures(1, &depthTex);
glBindTexture(GL_TEXTURE_2D, depthTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL,
GL_UNSIGNED_INT_24_8, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glClearDepth(0.75);
glClear(GL_DEPTH_BUFFER_BIT);
// Destination: the default framebuffer, depth 0 everywhere.
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glClearDepth(0.0);
glClear(GL_DEPTH_BUFFER_BIT);
// Blit the whole rect, but scissored to the BOTTOM-LEFT quadrant in GL coordinates.
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, width / 2, height / 2);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_DEPTH_BUFFER_BIT, GL_NEAREST);
glDisable(GL_SCISSOR_TEST);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
EXPECT_EQ(FirstGLError(), 0u);
const float inside = ReadDepthAt(width / 4, height / 4);
const float above = ReadDepthAt(width / 4, height - 1 - height / 4);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(inside, 0.75f, 1.0f / 4096.0f)
<< "GL (" << (width / 4) << ", " << (height / 4) << ") is inside the scissor box and should hold the "
<< "blitted 0.75, but read back " << inside;
EXPECT_NEAR(above, 0.0f, 1.0f / 4096.0f)
<< "GL (" << (width / 4) << ", " << (height - 1 - height / 4)
<< ") is ABOVE the scissor box and must still hold the cleared 0.0, but read back " << above
<< " (0.75 there means the depth blit landed in the mirrored band)";
glDeleteTextures(1, &depthTex);
glDeleteTextures(1, &colorTex);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
// The control: the same read against a user framebuffer, which never went through the
// declined path. It is what makes a failure above specific to the default framebuffer.
TEST_F(DepthStencilReadbackScenario, UserFramebufferDepthClearIsVisibleToReadPixels) {
if (!Ready()) return;
if (!BackendReadsDepthStencil()) {
GTEST_SKIP() << "backend " << Gl().BackendName() << " has no depth readback path";
}
HeadlessGL& gl = Gl();
const int width = 64;
const int height = 48;
GLuint fbo = 0, colorTex = 0, depthTex = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glGenTextures(1, &colorTex);
glBindTexture(GL_TEXTURE_2D, colorTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTex, 0);
glGenTextures(1, &depthTex);
glBindTexture(GL_TEXTURE_2D, depthTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, width, height, 0, GL_DEPTH_STENCIL,
GL_UNSIGNED_INT_24_8, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthTex, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
ASSERT_EQ(FirstGLError(), 0u);
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDepthMask(GL_TRUE);
glStencilMask(0xFFu);
glClearDepth(0.5);
glClearStencil(7);
glClear(GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
const float depth = ReadDepthAt(width / 2, height / 2);
const int stencil = ReadStencilAt(width / 2, height / 2);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_NEAR(depth, 0.5f, 1.0f / 4096.0f) << "user-framebuffer depth readback returned " << depth;
EXPECT_EQ(stencil, 7) << "user-framebuffer stencil readback returned " << stencil;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteTextures(1, &depthTex);
glDeleteTextures(1, &colorTex);
glDeleteFramebuffers(1, &fbo);
gl.EndFrame();
}
} // namespace MGITest
@@ -0,0 +1,215 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SsboArrayLengthScenario.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 - length() ON AN SSBO's UNSIZED ARRAY.
//
// GLSL's `arr.length()` on the trailing runtime array of a shader storage block is not a compile
// time constant: it is (bound range - the array's byte offset inside the block) / array stride,
// evaluated against whatever the descriptor actually covers. Three separate pieces of MobileGL
// have to agree for that to come out right - the byte offsets the block layout was compiled with,
// the buffer the frontend binding resolves to, and the offset/size a glBindBufferRange asked for -
// and a defect in any one of them shows up only as a wrong integer, never as an error.
//
// KHR-GL43.shader_storage_buffer_object.advanced-unsizedArrayLength-* (28 Magma failures, all 28
// passing on Espryt) reports exactly that: lengths too large by roughly the size of the members
// preceding the array. The cases here are the same shape, reduced to what can be asserted in one
// dispatch: a block with no preamble, a block with one, a two-element ARRAY OF BLOCKS (which
// consumes two consecutive bindings and is where the conformance failures concentrate), and the
// two glBindBufferRange forms.
//
// Every length is written into one output SSBO and read back, so a failure names the block and
// prints the number the shader saw.
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Bindings 0..3 are inputs (2 and 3 are the block array), 4 is the output.
constexpr const char* kComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) readonly buffer Input0 {
ivec4 g_input0[];
};
layout(std430, binding = 1) readonly buffer Input1 {
ivec4 pad1;
ivec4 data[];
} g_input1;
layout(std430, binding = 2) readonly buffer Input23 {
ivec4 data[];
} g_input23[2];
layout(std430, binding = 4) buffer Output {
int g_length[];
};
void main() {
g_length[0] = g_input0.length();
g_length[1] = g_input1.data.length();
g_length[2] = g_input23[0].data.length();
g_length[3] = g_input23[1].data.length();
}
)";
constexpr int kElementBytes = 16; // ivec4, std430
class SsboArrayLengthScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
GLint blocks = 0;
glGetIntegerv(GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS, &blocks);
if (blocks < 5) {
GTEST_SKIP() << "GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS is " << blocks << "; this needs 5";
}
m_program = CompileComputeProgram(kComputeSource);
ASSERT_NE(m_program, 0u) << m_buildLog;
}
void TearDown() override {
if (!Ready()) return;
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(m_buffers.size()), m_buffers.data());
if (m_program != 0) glDeleteProgram(m_program);
}
unsigned int CompileComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
m_buildLog = std::string("compute program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
// A buffer of `elements` ivec4s, filled with a recognisable pattern.
GLuint MakeStorageBuffer(int elements) {
std::vector<int> contents(static_cast<std::size_t>(elements) * 4, 41);
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(elements) * kElementBytes, contents.data(), GL_DYNAMIC_COPY);
m_buffers.push_back(buffer);
return buffer;
}
// Dispatches once and returns the four lengths the shader observed.
std::vector<int> RunAndReadLengths(GLuint outputBuffer) {
glUseProgram(m_program);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
std::vector<int> lengths(4, -1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, outputBuffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(lengths.size() * sizeof(int)), lengths.data());
return lengths;
}
unsigned int m_program = 0;
std::string m_buildLog;
std::vector<GLuint> m_buffers;
};
} // namespace
// glBindBufferBase everywhere: the plain case, and the one that pins the block array.
TEST_F(SsboArrayLengthScenario, WholeBufferBindingsReportTheElementCount) {
if (!Ready() || IsSkipped()) return;
// input1 carries one ivec4 of preamble before its runtime array, so a length that ignores
// the member offset comes back one too large there and only there.
const GLuint input0 = MakeStorageBuffer(7);
const GLuint input1 = MakeStorageBuffer(1 + 5);
const GLuint input2 = MakeStorageBuffer(3);
const GLuint input3 = MakeStorageBuffer(4);
const GLuint output = MakeStorageBuffer(4);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, input2);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<int> lengths = RunAndReadLengths(output);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(lengths[0], 7) << "Input0 (no preamble, 7 elements) reported length " << lengths[0];
EXPECT_EQ(lengths[1], 5) << "Input1 (1 ivec4 of preamble, 6 elements of storage) reported length "
<< lengths[1] << "; 6 means the array's byte offset inside the block was ignored";
EXPECT_EQ(lengths[2], 3) << "Input23[0] (binding 2, 3 elements) reported length " << lengths[2];
EXPECT_EQ(lengths[3], 4) << "Input23[1] (binding 3, 4 elements) reported length " << lengths[3]
<< "; a block array's second element must resolve to the NEXT binding";
}
// glBindBufferRange with a non-zero offset: length() must see only the bound window.
TEST_F(SsboArrayLengthScenario, RangeBindingsReportTheBoundWindow) {
if (!Ready() || IsSkipped()) return;
GLint alignment = 1;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &alignment);
if (alignment > 2 * kElementBytes) {
GTEST_SKIP() << "GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT is " << alignment
<< "; a two-element offset cannot be expressed";
}
const GLuint input0 = MakeStorageBuffer(7);
const GLuint input1 = MakeStorageBuffer(1 + 5);
const GLuint input2 = MakeStorageBuffer(3);
const GLuint input3 = MakeStorageBuffer(4);
const GLuint output = MakeStorageBuffer(4);
// Input0: window starts two elements in, so 5 remain.
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 0, input0, 2 * kElementBytes, 5 * kElementBytes);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, input1);
// Both elements of the block array get a window, so a failure says whether the array's
// FIRST element is handled and only the later ones are lost, or neither is.
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 2, input2, 0, 2 * kElementBytes);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 3, input3, 0, 2 * kElementBytes);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, output);
ASSERT_EQ(FirstGLError(), 0u);
const std::vector<int> lengths = RunAndReadLengths(output);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(lengths[0], 5) << "Input0 bound as [2 elements, 5 elements) reported length " << lengths[0]
<< "; 7 means glBindBufferRange's offset/size never reached the descriptor";
EXPECT_EQ(lengths[2], 2) << "Input23[0] bound as [0, 2 elements) reported length " << lengths[2];
EXPECT_EQ(lengths[3], 2) << "Input23[1] bound as [0, 2 elements) reported length " << lengths[3];
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, input0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, input3);
}
} // namespace MGITest
@@ -0,0 +1,310 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/SwizzleAccessRoutineScenario.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 - EVERY TEXTURE ACCESS ROUTINE READS THE SAME TEXEL OUT OF A usampler2DArray.
//
// KHR-GL33/GL40.texture_swizzle.smoke_access_idx_* sweeps the fourteen GLSL texture access
// routines against a 1x1x1 GL_RGBA32UI GL_TEXTURE_2D_ARRAY and asserts the fetched channel. On
// Espryt, `texture` and `textureGrad` pass while `textureLod`, `textureOffset`, `texelFetch`,
// `texelFetchOffset` and `textureLodOffset` fail - 21 cases per version, 42 across GL33 and GL40.
// The discriminator is the important part: the swizzle state is IDENTICAL across all of them, so
// swizzle delivery is not the defect; what differs is only how the routine is spelled, i.e. what
// SPIRV-Cross has to emit into ESSL for it.
//
// This scenario is that discriminator, reduced to something that fails in milliseconds: one draw
// per access routine against the same texture and the same swizzle, all reading the same texel.
// A routine that disagrees with the others is the defect, and the failure message names it.
//
// The shader shape is copied from the conformance test rather than idealised - including its
// `int(0)` level-of-detail argument, which is a desktop-GLSL implicit int->float conversion that
// ESSL does not have, and its zero offsets. Both are exactly the things a GLSL -> SPIR-V -> ESSL
// round trip can lose.
//
// DirectVulkan is the built-in control: it consumes the SPIR-V directly and never runs the ESSL
// emission, so a failure there would mean the scenario, not the backend.
#include <array>
#include <cstdint>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// The conformance test's own source texel, one recognisable value per channel.
constexpr std::uint32_t kSourceTexel[4] = {0x3FFFFFFFu, 0x7FFFFFFFu, 0xBFFFFFFFu, 0xFFFFFFFFu};
constexpr int kOutputWidth = 8;
constexpr int kOutputHeight = 8;
// The blank vertex shader the smoke test uses: a full-viewport strip with no attributes.
constexpr const char* kVertexSource = R"(#version 330 core
void main()
{
switch (gl_VertexID)
{
case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break;
case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break;
case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break;
}
}
)";
struct AccessRoutine {
const char* name; // as it appears in the conformance case name
const char* callText; // the whole TEXTURE_ACCESS(sampler, ARGUMENTS) expression
};
// Spelled exactly as gl3cTextureSwizzleTests.cpp's prepareArguments builds them for
// GL_TEXTURE_2D_ARRAY: three coordinates, `int(0)` for the level, ivec2 offsets.
constexpr AccessRoutine kRoutines[] = {
{"texture", "texture(smp, vec3(0, 0, 0))"},
{"textureLod", "textureLod(smp, vec3(0, 0, 0), int(0))"},
{"textureOffset", "textureOffset(smp, vec3(0, 0, 0), ivec2(0, 0))"},
{"texelFetch", "texelFetch(smp, ivec3(0, 0, 0), int(0))"},
{"texelFetchOffset", "texelFetchOffset(smp, ivec3(0, 0, 0), int(0), ivec2(0, 0))"},
{"textureLodOffset", "textureLodOffset(smp, vec3(0, 0, 0), int(0), ivec2(0, 0))"},
{"textureGrad", "textureGrad(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0))"},
{"textureGradOffset", "textureGradOffset(smp, vec3(0, 0, 0), vec2(0, 0), vec2(0, 0), ivec2(0, 0))"},
};
constexpr const char* kChannels[4] = {"x", "y", "z", "w"};
std::string FragmentSource(const AccessRoutine& routine, int channel) {
return std::string("#version 330 core\n\nuniform usampler2DArray smp;\n\nout uint out_color;\n\n"
"void main()\n{\n uint result = ") +
routine.callText + "." + kChannels[channel] + ";\n\n out_color = result;\n}\n";
}
class SwizzleAccessRoutineScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
// 1x1x1 RGBA32UI 2D array. Integer textures are not filterable, so NEAREST is
// mandatory, and a single level means every LOD argument must resolve to 0.
glGenTextures(1, &m_sourceTexture);
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGBA32UI, 1, 1, 1);
glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, 1, 1, 1, GL_RGBA_INTEGER, GL_UNSIGNED_INT,
kSourceTexel);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
ASSERT_EQ(FirstGLError(), 0u) << "source texture setup left a GL error behind";
// 8x8 R32UI render target, read back with glReadPixels.
glGenTextures(1, &m_outputTexture);
glBindTexture(GL_TEXTURE_2D, m_outputTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, kOutputWidth, kOutputHeight);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_outputTexture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glGenVertexArrays(1, &m_vao);
ASSERT_EQ(FirstGLError(), 0u) << "output framebuffer setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_outputTexture != 0) glDeleteTextures(1, &m_outputTexture);
if (m_sourceTexture != 0) glDeleteTextures(1, &m_sourceTexture);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void SetSwizzle(GLenum r, GLenum g, GLenum b, GLenum a) {
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_R, static_cast<GLint>(r));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_G, static_cast<GLint>(g));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_B, static_cast<GLint>(b));
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_SWIZZLE_A, static_cast<GLint>(a));
}
// Renders one access routine into the 8x8 target and returns every texel it wrote.
// Returns an empty vector (with a gtest failure already recorded) if the program did
// not build.
std::vector<std::uint32_t> Render(const AccessRoutine& routine, int channel) {
const std::string fragment = FragmentSource(routine, channel);
std::string error;
const unsigned int program = CompileProgram(kVertexSource, fragment.c_str(), &error);
if (program == 0) {
ADD_FAILURE() << routine.name << " channel " << kChannels[channel]
<< ": program did not build: " << error << "\n--- source ---\n"
<< fragment;
return {};
}
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glViewport(0, 0, kOutputWidth, kOutputHeight);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
const GLuint clearValue[4] = {0xDEADBEEFu, 0u, 0u, 0u};
glClearBufferuiv(GL_COLOR, 0, clearValue);
glUseProgram(program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D_ARRAY, m_sourceTexture);
const GLint location = glGetUniformLocation(program, "smp");
glUniform1i(location, 0);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
std::vector<std::uint32_t> texels(static_cast<std::size_t>(kOutputWidth) * kOutputHeight, 0);
glReadPixels(0, 0, kOutputWidth, kOutputHeight, GL_RED_INTEGER, GL_UNSIGNED_INT, texels.data());
glUseProgram(0);
glDeleteProgram(program);
return texels;
}
// Asserts every texel equals `expected`, naming the routine and the first offender.
void ExpectAllTexels(const AccessRoutine& routine, int channel, std::uint32_t expected,
const std::vector<std::uint32_t>& texels) {
if (texels.empty()) return;
std::size_t offenders = 0;
std::uint32_t firstBad = 0;
std::size_t firstIndex = 0;
for (std::size_t i = 0; i < texels.size(); ++i) {
if (texels[i] == expected) continue;
if (offenders == 0) {
firstBad = texels[i];
firstIndex = i;
}
++offenders;
}
EXPECT_EQ(offenders, 0u)
<< routine.name << "(...)." << kChannels[channel] << " returned 0x" << std::hex << firstBad
<< " instead of 0x" << expected << std::dec << " at texel " << firstIndex << " (" << offenders
<< " of " << texels.size() << " wrong)";
}
GLuint m_sourceTexture = 0;
GLuint m_outputTexture = 0;
GLuint m_fbo = 0;
GLuint m_vao = 0;
};
} // namespace
// Identity swizzle: every routine must fetch the channel it was asked for. This is the
// scenario's floor - it does not involve swizzling at all, so a failure here is purely about
// how the access routine itself survives the trip to the backend.
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineFetchesTheSameTexelUnderTheIdentitySwizzle) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA);
ASSERT_EQ(FirstGLError(), 0u);
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, kSourceTexel[channel], texels);
}
}
Gl().EndFrame();
}
// A real swizzle, applied to every routine. Reversing the channels means a routine that
// silently drops the swizzle returns the UNSWIZZLED texel rather than nothing, so the
// failure distinguishes "swizzle lost" from "fetch broken".
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesAReversedSwizzle) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_ALPHA, GL_BLUE, GL_GREEN, GL_RED);
ASSERT_EQ(FirstGLError(), 0u);
const std::uint32_t expected[4] = {kSourceTexel[3], kSourceTexel[2], kSourceTexel[1], kSourceTexel[0]};
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, expected[channel], texels);
}
}
Gl().EndFrame();
}
// Program churn: the shape that made the conformance suite fail, reduced.
//
// The swizzle smoke test builds one program per swizzle combination - 1,296 per case - and
// DirectGLES created a driver shader object per attached shader without ever calling
// glDeleteShader. glDeleteShader only FLAGS a shader for deletion (the driver frees it once
// nothing has it attached), so without that call the program's own deletion could not free
// them either: eight cases left ~20,000 live driver shaders behind, the Adreno ES driver
// passed its ceiling, and it began mis-serving shaders - first the sampling variants with the
// most image operands (textureLod/texelFetch/*Offset), while plain texture/textureGrad still
// worked. On device this loop plus a value check is the whole defect.
//
// HONEST LIMIT OF THIS TEST: llvmpipe has no such ceiling, so this passes here whether or not
// the leak is present - it cannot fail on the CI lane. It is a standing guard for the SHAPE
// (build many programs, keep reading the right texel) and the place to raise the iteration
// count if a driver ceiling ever needs reproducing; the leak itself is pinned by device
// measurement (VmRSS flat at ~137 MB across the 32-case family, against 132 -> 154 MB and
// still climbing before the fix).
TEST_F(SwizzleAccessRoutineScenario, RepeatedProgramBuildsKeepFetchingTheSameTexel) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA);
ASSERT_EQ(FirstGLError(), 0u);
// One routine from each side of the device's failure order, so a ceiling that takes the
// vulnerable one down first is still caught.
const AccessRoutine& plain = kRoutines[0]; // texture
const AccessRoutine& explicitLod = kRoutines[1]; // textureLod
constexpr int kIterations = 200;
for (int i = 0; i < kIterations; ++i) {
const AccessRoutine& routine = (i % 2 == 0) ? plain : explicitLod;
const int channel = i % 4;
const std::vector<std::uint32_t> texels = Render(routine, channel);
if (::testing::Test::HasFailure()) return; // a build failure repeats 200 times; say it once
ExpectAllTexels(routine, channel, kSourceTexel[channel], texels);
if (::testing::Test::HasFailure()) {
ADD_FAILURE() << "diverged at iteration " << i << " of " << kIterations;
return;
}
}
EXPECT_EQ(FirstGLError(), 0u) << "the churn loop left a GL error behind";
Gl().EndFrame();
}
// GL_ONE and GL_ZERO, which the conformance table spells as the literal values 1 and 0 and
// which the backend has to synthesise rather than fetch.
TEST_F(SwizzleAccessRoutineScenario, EveryAccessRoutineSeesConstantSwizzleSources) {
if (!Ready() || IsSkipped()) return;
SetSwizzle(GL_ONE, GL_ZERO, GL_ONE, GL_ZERO);
ASSERT_EQ(FirstGLError(), 0u);
const std::uint32_t expected[4] = {1u, 0u, 1u, 0u};
for (const AccessRoutine& routine : kRoutines) {
for (int channel = 0; channel < 4; ++channel) {
const std::vector<std::uint32_t> texels = Render(routine, channel);
EXPECT_EQ(FirstGLError(), 0u) << routine.name << " left a GL error behind";
ExpectAllTexels(routine, channel, expected[channel], texels);
}
}
Gl().EndFrame();
}
} // namespace MGITest