From b1d60523e4b34f9b8a6e290f10a41a4167ba0e8a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 04:28:55 -0400 Subject: [PATCH] [Fix] (Magma): an upload into an image the open recording already wrote, or that still has a queued clear, is submitted after that work - a glTexSubImage after a clear or a draw into the level no longer ends up under it --- .../Renderer/VkTextureManager.cpp | 43 +- .../DirectVulkan/Renderer/VkTextureManager.h | 26 ++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 110 ++++- .../DirectVulkan/Renderer/VulkanRenderer.h | 22 +- MobileGL/MG_IntegrationTest/CMakeLists.txt | 1 + .../PartialUploadOverGpuContentScenario.cpp | 428 ++++++++++++++++++ 6 files changed, 623 insertions(+), 7 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 9aed0a4e..c00b0752 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -1293,6 +1293,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + VkTextureManager::TextureResource* VkTextureManager::FindTextureResource( + MG_State::GLState::ITextureObject& textureOrView) { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() ? &it->second : nullptr; + } + + Bool VkTextureManager::HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() && it->second.image != VK_NULL_HANDLE; + } + + Bool VkTextureManager::HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const { + auto& storage = StorageTextureOf(textureOrView); + auto it = m_textureResources.find(MakeTextureIdentity(&storage)); + if (it == m_textureResources.end() || it->second.image == VK_NULL_HANDLE) { + return false; + } + return it->second.syncedShapeVersion == storage.GetShapeVersion() && + it->second.syncedContentVersion != storage.GetContentVersion(); + } + + Bool VkTextureManager::WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const { + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(textureOrView))); + return it != m_textureResources.end() && it->second.lastRecordingWriteGeneration == m_recordingGeneration; + } + + void VkTextureManager::StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture) { + if (texture == nullptr) { + return; + } + auto it = m_textureResources.find(MakeTextureIdentity(&StorageTextureOf(*texture))); + if (it != m_textureResources.end()) { + StampResourceRecordingWrite(it->second); + } + } + void VkTextureManager::UpdateTrackedImageLayout(MG_State::GLState::ITextureObject* texture, VkImageLayout newLayout) { MOBILEGL_ASSERT(texture != nullptr, "UpdateTrackedImageLayout: texture is null"); auto it = m_textureResources.find(MakeTextureIdentity(texture)); @@ -1321,7 +1357,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { "UpdateTrackedImageLayoutAfterAttachmentWrite: textureId=%d mipLevel=%u out of range %u", texture->GetExternalIndex(), writtenMipLevel, resource.mipLevels); // Pre-pass stream bookkeeping: the render pass that just ended wrote this image. - StampResourceRecordingUse(resource); + StampResourceRecordingWrite(resource); if (resource.layout != newLayout && resource.mipLevels > 1) { VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; @@ -1435,8 +1471,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource->aspect, 0, resource->mipLevels); MOBILEGL_ASSERT(ok, "TransitionTextureForStorageImage: transition failed for textureId=%d", texture.GetExternalIndex()); - // Pre-pass stream bookkeeping: a command referencing the image was recorded. - StampResourceRecordingUse(*resource); + // Pre-pass stream bookkeeping: a command referencing the image was recorded, and an + // image unit may store to it. + StampResourceRecordingWrite(*resource); return ok; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h index 117b8424..6e77a374 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h @@ -320,6 +320,11 @@ public: // into the frame's PRE command buffer - which executes strictly before the // frame's commands - instead of splitting the active render pass. Uint64 lastRecordingGeneration = 0; + // The same, for the last recorded command that WRITES the image (a render pass it is an + // attachment of, a clear, a blit/copy into it, a mip generation, a storage-image binding). + // An upload into an image written this recording has to be ordered after that write + // (VulkanRenderer::OrderPendingUploadAfterRecording). + Uint64 lastRecordingWriteGeneration = 0; // Snapshot of ITextureObject::GetContentVersion() at the last successful sync; // lets SyncTexture skip the whole re-check/re-upload when content is unchanged. Uint64 syncedContentVersion = 0; @@ -362,6 +367,7 @@ public: std::swap(this->storageUsageResolved, that.storageUsageResolved); std::swap(this->syncedTextureParamsVersion, that.syncedTextureParamsVersion); std::swap(this->lastRecordingGeneration, that.lastRecordingGeneration); + std::swap(this->lastRecordingWriteGeneration, that.lastRecordingWriteGeneration); std::swap(this->syncedContentVersion, that.syncedContentVersion); std::swap(this->syncedMipLevelCount, that.syncedMipLevelCount); std::swap(this->syncedShapeVersion, that.syncedShapeVersion); @@ -551,6 +557,26 @@ public: Bool WasTouchedThisRecording(const TextureResource& resource) const { return resource.lastRecordingGeneration == m_recordingGeneration; } + // A write is a use too. See TextureResource::lastRecordingWriteGeneration. + void StampResourceRecordingWrite(TextureResource& resource) const { + resource.lastRecordingGeneration = m_recordingGeneration; + resource.lastRecordingWriteGeneration = m_recordingGeneration; + } + void StampTextureRecordingWrite(MG_State::GLState::ITextureObject* texture); + // Upload-ordering queries (VulkanRenderer::OrderPendingUploadAfterRecording). None of them + // syncs, so nothing pending is uploaded by asking. All answer for the STORAGE texture. + // + // Uploads are recorded into a batch submitted AHEAD of the frame's recording + // (FlushPendingUploads), so an upload into an image the open recording already WROTE lands + // before that write, which then overwrites the newer texels. (An image the recording only + // read keeps the old trade: its earlier reads see the newer texels.) + Bool HasLiveImage(MG_State::GLState::ITextureObject& textureOrView) const; + // The next sync would record client texels into the image that is live now: same shape (a + // shape change mints a new image instead) and content moved since the last sync. + Bool HasPendingTexelUpload(MG_State::GLState::ITextureObject& textureOrView) const; + Bool WasTextureWrittenThisRecording(MG_State::GLState::ITextureObject& textureOrView) const; + // The live resource, or null. + TextureResource* FindTextureResource(MG_State::GLState::ITextureObject& textureOrView); // Records that this texture is bound to a GL image unit, so its image must carry // VK_IMAGE_USAGE_STORAGE_BIT. Must be called before NeedsStorageImagePreparation, and // therefore before the render pass is committed: an image that has to be upgraded is diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b600511c..aa602e83 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -1801,6 +1801,9 @@ void main() { outBinding.label, texture->GetExternalIndex()); return false; } + if (!isReadFramebuffer) { + textureManager.StampResourceRecordingWrite(*resource); + } outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -1917,6 +1920,9 @@ void main() { outBinding.label, texture->GetExternalIndex(), static_cast(requiredAspectMask)); return false; } + if (!isReadFramebuffer) { + textureManager.StampResourceRecordingWrite(*resource); + } outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -1957,6 +1963,7 @@ void main() { texture.GetExternalIndex(), mipLevel, resource->mipLevels); return false; } + textureManager.StampResourceRecordingWrite(*resource); outBinding.image = resource->image; outBinding.trackedLayout = &resource->layout; @@ -6651,6 +6658,15 @@ void main() { VkRenderPassManager::EndRenderPass(frame.commandBuffer); activeRenderPass = nullptr; } + // The syncs below upload each sampled texture's pending texels into a batch that runs + // ahead of this recording; one whose image the recording already cleared or drew into + // would be overwritten by that earlier work. Submit it first, here, before anything below + // records into the frame or decides from the active pass. + for (auto* sampledTexture : sampledTextures) { + if (sampledTexture != nullptr && OrderPendingUploadAfterRecording(frame, *sampledTexture)) { + activeRenderPass = nullptr; + } + } Bool needSampledTextureTransitions = false; auto& sampledResources = m_sampledResourcesScratch; sampledResources.assign(sampledTextures.size(), nullptr); @@ -7349,6 +7365,7 @@ void main() { m_renderPassManager->QueueRenderbufferClear( {.mask = GL_COLOR_BUFFER_BIT, .color = payload.color}, attachment); } else if (attachment.IsTexture()) { + SettleTexelsBeforeQueuedClear(*fbo, attachment); m_clearManager->QueueClear({.mask = GL_COLOR_BUFFER_BIT, .color = payload.color}, attachment); } @@ -7360,6 +7377,19 @@ void main() { return; } + if ((deferredMask & GL_COLOR_BUFFER_BIT) != 0) { + for (const auto drawBuffer : fbo->GetDrawBuffers()) { + if (drawBuffer != FramebufferAttachmentType::None) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(drawBuffer)); + } + } + } + if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Depth)); + } + if ((deferredMask & GL_STENCIL_BUFFER_BIT) != 0) { + SettleTexelsBeforeQueuedClear(*fbo, fbo->GetAttachment(FramebufferAttachmentType::Stencil)); + } m_clearManager->QueueClear(deferredMask, payload, *fbo); m_renderPassManager->QueueRenderbufferClear(deferredMask, payload, *fbo); } @@ -7437,6 +7467,7 @@ void main() { if (!attachment.IsTexture()) { return; } + SettleTexelsBeforeQueuedClear(framebuffer, attachment); m_clearManager->QueueClear(payload, attachment); }; @@ -7795,7 +7826,8 @@ void main() { } Bool VulkanRenderer::MaterializePendingClearForTexture(VkCommandBuffer commandBuffer, - MG_State::GLState::ITextureObject& texture) { + MG_State::GLState::ITextureObject& texture, + Bool uploadPendingTexels) { Vector pendingClears; if (!m_clearManager->GetPendingClears(&texture, pendingClears)) { return true; @@ -7807,8 +7839,9 @@ void main() { commandBuffer != m_frameContext.GetCurrent().commandBuffer, "MaterializePendingClearForTexture requires no active render pass on the target buffer"); - auto* resource = m_textureManager->SyncTextureAndGetDescriptor(texture); - if (resource == nullptr) { + auto* resource = uploadPendingTexels ? m_textureManager->SyncTextureAndGetDescriptor(texture) + : m_textureManager->FindTextureResource(texture); + if (resource == nullptr || resource->image == VK_NULL_HANDLE) { // Declined sync (an incomplete texture, say). Nothing to clear into, and every line // below dereferences this - the assert that used to stand here is compiled out of // every build past DEBUG. @@ -7960,6 +7993,9 @@ void main() { "MaterializePendingClearForTexture: failed to transition textureId=%d to sampled layout", texture.GetExternalIndex()); resource->layout = sampledLayout; + // The clear is now a write in this recording, which is what orders a later upload into + // the image after it (OrderPendingUploadAfterRecording). + m_textureManager->StampResourceRecordingWrite(*resource); m_clearManager->PopPendingClear(&texture); MGLOG_D("MaterializePendingClearForTexture: textureId=%d pending clear materialized", @@ -7967,6 +8003,62 @@ void main() { return true; } + Bool VulkanRenderer::OrderPendingUploadAfterRecording(FrameContext::FrameData& frame, + MG_State::GLState::ITextureObject& texture) { + if (!m_textureManager->HasPendingTexelUpload(texture)) { + return false; + } + // An open pass drawing into the image stamps that write only when it ends. + if (const auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); + activeRenderPass != nullptr && ActiveRenderPassUsesTexture(*activeRenderPass, texture)) { + VkRenderPassManager::EndRenderPass(frame.commandBuffer); + } + const Bool clearFirst = m_clearManager->HasPendingClear(&texture); + if (!clearFirst && !m_textureManager->WasTextureWrittenThisRecording(texture)) { + // Nothing recorded writes the image: the upload may run ahead of the recording. + return false; + } + if (!frame.isCommandRecording) { + m_frameContext.BeginCommandRecording(); + } + if (VkRenderPassManager::GetActiveRenderPass() != nullptr) { + VkRenderPassManager::EndRenderPass(frame.commandBuffer); + } + if (clearFirst && !MaterializePendingClearForTexture(frame.commandBuffer, texture, + /*uploadPendingTexels=*/false)) { + MGLOG_E_ONCE("OrderPendingUploadAfterRecording: textureId=%d kept its queued clear; it may land over " + "texels written after it", + texture.GetExternalIndex()); + } + if (!FlushPendingCommands()) { + MGLOG_E_ONCE("OrderPendingUploadAfterRecording: submit failed; textureId=%d's upload may run ahead of " + "earlier work on it", + texture.GetExternalIndex()); + return false; + } + m_frameContext.BeginCommandRecording(); + // New command buffer: the same reset SetupDraw applies when it begins one. + m_lastSampledSetValid = false; + return true; + } + + void VulkanRenderer::SettleTexelsBeforeQueuedClear( + const MG_State::GLState::FramebufferObject& framebuffer, + const MG_State::GLState::FramebufferAttachmentObject& attachment) { + // The default framebuffer's attachments are placeholders: its clears land on the + // swapchain image (MaterializePendingClearForDefaultFramebuffer) and it has no texels. + if (framebuffer.IsDefaultFramebuffer() || !attachment.IsTexture() || attachment.IsRenderbuffer()) { + return; + } + const auto texture = attachment.GetTexture(); + if (!texture || (m_textureManager->HasLiveImage(*texture) && + !m_textureManager->HasPendingTexelUpload(*texture))) { + return; + } + OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *texture); + m_textureManager->SyncTextureAndGetDescriptor(*texture); + } + Bool VulkanRenderer::MaterializeMultisamplePendingClear(VkCommandBuffer commandBuffer, MG_State::GLState::ITextureObject& texture, VkTextureManager::TextureResource& resource, @@ -9667,6 +9759,7 @@ void main() { const Bool dstClearReady = materializeClear(dstEndpoint); MOBILEGL_ASSERT(dstClearReady, "%s: failed to materialize pending clear for destination objectId=%u", __func__, CopyImageEndpointName(dstEndpoint)); + m_textureManager->StampTextureRecordingWrite(dstEndpoint.Texture.get()); const VkImageLayout srcOriginalLayout = *srcImage.trackedLayout; const VkImageLayout dstOriginalLayout = *dstImage.trackedLayout; @@ -9875,6 +9968,13 @@ void main() { } auto& frame = m_frameContext.GetCurrent(); + if (!readFbo->IsDefaultFramebuffer()) { + // The read syncs the source texture, whose pending texels must land after work already + // recorded against it (see OrderPendingUploadAfterRecording). + if (const auto readTexture = readFbo->GetAttachment(readFbo->GetReadBuffer()).GetTexture()) { + OrderPendingUploadAfterRecording(frame, *readTexture); + } + } if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); } @@ -10667,6 +10767,9 @@ void main() { return; } + // The sync below uploads pending texels, which must land after work already recorded + // against this image (see OrderPendingUploadAfterRecording). + OrderPendingUploadAfterRecording(m_frameContext.GetCurrent(), *textureObject); auto* resource = m_textureManager->SyncTextureAndGetDescriptor(*textureObject); // Two shapes end up in the same place, and for the same reason: the GL level being read has // no GPU storage, so UploadDirtyMipLevels never wrote it and the CPU shadow is the ONLY copy @@ -10962,6 +11065,7 @@ void main() { resource = m_textureManager->SyncTextureAndGetDescriptor(*texture); MOBILEGL_ASSERT(resource != nullptr && resource->image != VK_NULL_HANDLE, "GenerateMipmap failed to resync the backend texture after allocating mip storage."); + m_textureManager->StampResourceRecordingWrite(*resource); if (resource->layout == VK_IMAGE_LAYOUT_UNDEFINED) { const VkImageLayout finalLayout = ResolveGenerateMipmapFinalLayout(resource->aspect); Bool transitioned = VkTextureManager::TransitionImageLayout( diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index c3d30855..42909bd2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -1376,8 +1376,28 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_State::GLState::ITextureObject& texture, Uint32 mipLevel, Uint32 depthSlice, const VkClearValue& clearValue, VkImageLayout finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + // Records the texture's queued clears. By default the texture is synced first, which + // uploads any pending texels BEFORE the clear - right only for texels written before it. + // `uploadPendingTexels = false` leaves them pending (OrderPendingUploadAfterRecording). Bool MaterializePendingClearForTexture(VkCommandBuffer commandBuffer, - MG_State::GLState::ITextureObject& texture); + MG_State::GLState::ITextureObject& texture, + Bool uploadPendingTexels = true); + // Texture uploads run in a batch submitted ahead of the frame's recording, so the upload + // the next sync of `texture` records would land BEFORE a write already recorded into the + // same image (a render, clear, blit or copy), and before a clear still queued for it (such + // a clear predates the texels: SettleTexelsBeforeQueuedClear). When either holds, this + // records the queued clear without the texels and submits the recording, so the upload + // follows both. An image the recording only READ keeps the old trade: no submit, and its + // earlier reads see the newer texels. It may end the render pass and replace + // frame.commandBuffer (recording again on return), so call it only where the caller holds + // no render-pass or command-buffer state across it. Returns whether it submitted. + Bool OrderPendingUploadAfterRecording(FrameContext::FrameData& frame, + MG_State::GLState::ITextureObject& texture); + // Called for each texture a glClear/glClearBuffer* is about to queue a clear on: uploads + // whatever texels are still pending (and mints the image if there is none), so that any + // texel pending when the clear is materialized was written after it. + void SettleTexelsBeforeQueuedClear(const MG_State::GLState::FramebufferObject& framebuffer, + const MG_State::GLState::FramebufferAttachmentObject& attachment); // The multisample arm of the above. Split out rather than branched inline because it // shares none of the transfer path: a multisample image carries no TRANSFER_DST usage, so // neither the TRANSFER_DST transition nor vkCmdClearColorImage is legal on one. diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 3c4d3498..e960b6fd 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -89,6 +89,7 @@ add_executable(MobileGLIntegrationTest Scenarios/NonCoreImageFormatScenario.cpp Scenarios/ImageSizeAfterRespecScenario.cpp Scenarios/ImageBindableRemintScenario.cpp + Scenarios/PartialUploadOverGpuContentScenario.cpp Scenarios/SsboDeclarationFormScenario.cpp Scenarios/Glsl420DeclarationScenario.cpp Scenarios/IoBlockNameCollisionScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp new file mode 100644 index 00000000..617ccf3a --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp @@ -0,0 +1,428 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.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 - A SMALL glTexSubImage INTO A LEVEL THE GPU HAS WRITTEN. +// +// Espryt keeps a CPU shadow of every level and syncs a pending client write by uploading the +// region it covers (SyncMipmapsToBackend's sub-rect path). The shadow never sees a GPU write, so +// only the region may go up: anything more puts the shadow's stale texels over what the GPU drew. +// The region upload used to be skipped whenever the shadow's bytes had to be CONVERTED on the way +// to the driver - a three-channel format the driver cannot render is stored four-channel and every +// upload is repacked - and whenever the ES upload is shaped differently from the shadow (a 1D +// array, stored as a 2D array one texel high): those levels went up WHOLE, and a 2x2 write erased +// the rest of the level. +// +// Every case uploads a level, lets the GPU clear it, writes one 2x2 box, and reads the level back: +// the clear outside the box, the write inside. The formats are picked by what they cost the ES +// upload, which is per driver - on Mesa llvmpipe GL_SRGB8, GL_RGB8_SNORM and GL_RGB8UI are stored +// four-channel (see ThreeChannelAttachmentScenario) while GL_RGB16F is native; ES has no renderable +// three-channel format at all, so on a phone all four are converted. GL_RGBA8 is the control that +// never converts. A format a backend cannot attach is skipped, not failed. +// +// The second half is about ORDER, with GL_RGBA8. Magma queues a clear until the texture's next +// use and records texel uploads into a batch submitted ahead of the frame's recorded commands, so +// a write issued after a clear of (or a draw into) the level reached the image FIRST and the +// earlier clear or draw landed over it: the box read back green. Each order is covered - the +// clear already recorded, still queued, synced by a draw or by the readback, the reverse order +// in which the clear must win, and a draw instead of a clear. + +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + constexpr int kEdge = 8; + constexpr int kBoxAt = 2; + constexpr int kBoxEdge = 2; + + constexpr const char* kVS = R"(#version 430 core +void main() { + vec2 positions[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); +} +)"; + + // How a value in [0, 1] is spelled in the client data, and how the level is cleared and + // read back. Integer levels spell 1 as kIntegerOne. + enum class Encoding { Unorm8, Snorm8, Float32, Uint8 }; + constexpr unsigned kIntegerOne = 9; + + struct Format { + GLenum internalFormat; + GLenum clientFormat; + GLenum clientType; + int components; + Encoding encoding; + }; + + std::vector Fill(const Format& f, int texels, float r, float g, float b) { + const float rgba[4] = {r, g, b, 1.0f}; + const std::size_t componentBytes = f.encoding == Encoding::Float32 ? 4u : 1u; + std::vector out(static_cast(texels) * f.components * componentBytes); + for (int t = 0; t < texels; ++t) { + for (int c = 0; c < f.components; ++c) { + std::uint8_t* dst = out.data() + (static_cast(t) * f.components + c) * componentBytes; + const float v = rgba[c]; + switch (f.encoding) { + case Encoding::Unorm8: *dst = static_cast(std::lround(v * 255.0f)); break; + case Encoding::Snorm8: + *dst = static_cast(static_cast(std::lround(v * 127.0f))); + break; + case Encoding::Float32: std::memcpy(dst, &v, sizeof(v)); break; + case Encoding::Uint8: *dst = static_cast(std::lround(v * kIntegerOne)); break; + } + } + } + return out; + } + + class PartialUploadOverGpuContentScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_scratch = MakeColorFbo(4, 4); + glGenVertexArrays(1, &m_vao); + glGenFramebuffers(1, &m_fbo); + FirstGLError(); + } + + void TearDown() override { + if (!Ready()) return; + glUseProgram(0); + for (GLuint p : m_programs) glDeleteProgram(p); + if (!m_textures.empty()) glDeleteTextures(static_cast(m_textures.size()), m_textures.data()); + if (m_vao) glDeleteVertexArrays(1, &m_vao); + if (m_fbo) glDeleteFramebuffers(1, &m_fbo); + BindDefaultFramebuffer(); + DestroyColorFbo(m_scratch); + glViewport(0, 0, Gl().Width(), Gl().Height()); + FirstGLError(); + } + + GLuint NewTexture(GLenum target) { + GLuint t = 0; + glGenTextures(1, &t); + m_textures.push_back(t); + glBindTexture(target, t); + glTexParameteri(target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, 0); + glBindTexture(target, 0); + return t; + } + + // A draw that samples the texture, which is what syncs its pending writes to the + // driver (an FBO attach alone may find the framebuffer already synced). + void SyncBySampling(GLenum target, GLuint tex, const char* samplerType, const char* coord) { + std::string fs = std::string("#version 430 core\nuniform ") + samplerType + + " u_tex;\nout vec4 o_color;\nvoid main() { o_color = vec4(texelFetch(u_tex, " + coord + + ", 0)); }\n"; + std::string error; + const GLuint program = CompileProgram(kVS, fs.c_str(), &error); + ASSERT_NE(program, 0u) << error; + m_programs.push_back(program); + BindFbo(m_scratch); + glUseProgram(program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(target, tex); + glUniform1i(glGetUniformLocation(program, "u_tex"), 0); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glBindTexture(target, 0); + glUseProgram(0); + BindDefaultFramebuffer(); + glFinish(); + EXPECT_EQ(FirstGLError(), 0u) << "sampling sync"; + } + + // Attaches (tex, layer) to the scratch framebuffer; false when this backend cannot. + bool Attach(GLuint tex, int layer) { + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + if (layer >= 0) { + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, layer); + } else { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + } + return glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + } + + void Detach() { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0); + BindDefaultFramebuffer(); + } + + // The GPU write the shadow never sees: the whole level (or layer) to green. + void GpuClearGreen(const Format& f) { + if (f.encoding == Encoding::Uint8) { + const GLuint green[4] = {0u, kIntegerOne, 0u, 1u}; + glClearBufferuiv(GL_COLOR, 0, green); + } else { + const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f}; + glClearBufferfv(GL_COLOR, 0, green); + } + } + + // The attached image's rgb, in units of "one", width x height texels. + std::vector ReadRgb(const Format& f, int width, int height) { + std::vector rgb(static_cast(width * height) * 3u); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + if (f.encoding == Encoding::Uint8) { + std::vector texels(static_cast(width * height) * 4u, 0xA5A5A5A5u); + glReadPixels(0, 0, width, height, GL_RGBA_INTEGER, GL_UNSIGNED_INT, texels.data()); + for (int i = 0; i < width * height; ++i) + for (int c = 0; c < 3; ++c) + rgb[i * 3 + c] = static_cast(texels[i * 4 + c]) / kIntegerOne; + } else { + std::vector texels(static_cast(width * height) * 4u, -7.0f); + glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, texels.data()); + for (int i = 0; i < width * height; ++i) + for (int c = 0; c < 3; ++c) rgb[i * 3 + c] = texels[i * 4 + c]; + } + EXPECT_EQ(FirstGLError(), 0u) << "readback"; + return rgb; + } + + // Green everywhere but blue inside [boxX, boxX + boxW) x [boxY, boxY + boxH). + static void ExpectClearAroundBox(const std::vector& rgb, int width, int height, int boxX, int boxY, + int boxW, int boxH) { + int bad = 0; + std::ostringstream first; + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) { + const bool inBox = x >= boxX && x < boxX + boxW && y >= boxY && y < boxY + boxH; + const float want[3] = {0.0f, inBox ? 0.0f : 1.0f, inBox ? 1.0f : 0.0f}; + const float* got = &rgb[static_cast(y * width + x) * 3u]; + bool texelBad = false; + for (int c = 0; c < 3; ++c) texelBad |= std::fabs(got[c] - want[c]) > 0.02f; + if (texelBad && bad++ == 0) { + first << "(" << x << "," << y << ") got " << got[0] << "," << got[1] << "," << got[2] + << " want " << want[0] << "," << want[1] << "," << want[2]; + } + } + EXPECT_EQ(bad, 0) << bad << " of " << width * height << " texels wrong; first " << first.str() + << " (red outside the box: the stale shadow went back up; green inside it: the " + "write landed before the clear or draw)"; + } + + // Upload red, sync, clear green on the GPU, sync, write a blue 2x2 box, sync, read. + void Run2D(const Format& f) { + const GLuint t = NewTexture(GL_TEXTURE_2D); + const bool integer = f.encoding == Encoding::Uint8; + const char* sampler = integer ? "usampler2D" : "sampler2D"; + const auto red = Fill(f, kEdge * kEdge, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_2D, t); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, static_cast(f.internalFormat), kEdge, kEdge, 0, f.clientFormat, + f.clientType, red.data()); + glBindTexture(GL_TEXTURE_2D, 0); + ASSERT_EQ(FirstGLError(), 0u) << "definition"; + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + if (!Attach(t, -1)) { + Detach(); + GTEST_SKIP() << "the format does not attach on backend " << Gl().BackendName(); + } + GpuClearGreen(f); + Detach(); + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + const auto blue = Fill(f, kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, f.clientFormat, f.clientType, + blue.data()); + glBindTexture(GL_TEXTURE_2D, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + SyncBySampling(GL_TEXTURE_2D, t, sampler, "ivec2(0)"); + ASSERT_TRUE(Attach(t, -1)); + const auto rgb = ReadRgb(f, kEdge, kEdge); + Detach(); + ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge); + } + + // Building blocks of the RGBA8 ordering cases: the same texels, issued in the orders + // a backend that defers work may get wrong. + static Format Rgba8() { return {GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}; } + + GLuint NewRedRgba8() { + const GLuint t = NewTexture(GL_TEXTURE_2D); + const auto red = Fill(Rgba8(), kEdge * kEdge, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data()); + glBindTexture(GL_TEXTURE_2D, 0); + EXPECT_EQ(FirstGLError(), 0u) << "definition"; + return t; + } + + void SampleRgba8(GLuint t) { SyncBySampling(GL_TEXTURE_2D, t, "sampler2D", "ivec2(0)"); } + + void ClearRgba8Green(GLuint t) { + ASSERT_TRUE(Attach(t, -1)); + GpuClearGreen(Rgba8()); + Detach(); + } + + // A draw that writes green over the whole level. + void DrawRgba8Green(GLuint t) { + std::string error; + const GLuint program = CompileProgram( + kVS, "#version 430 core\nout vec4 o_color;\nvoid main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }\n", + &error); + ASSERT_NE(program, 0u) << error; + m_programs.push_back(program); + ASSERT_TRUE(Attach(t, -1)); + glViewport(0, 0, kEdge, kEdge); + glUseProgram(program); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glUseProgram(0); + Detach(); + glViewport(0, 0, Gl().Width(), Gl().Height()); + } + + void WriteRgba8BlueBox(GLuint t) { + const auto blue = Fill(Rgba8(), kBoxEdge * kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_2D, t); + glTexSubImage2D(GL_TEXTURE_2D, 0, kBoxAt, kBoxAt, kBoxEdge, kBoxEdge, GL_RGBA, GL_UNSIGNED_BYTE, + blue.data()); + glBindTexture(GL_TEXTURE_2D, 0); + } + + // Green everywhere, and blue in the box when `boxWins`. + void ExpectRgba8(GLuint t, bool boxWins) { + ASSERT_TRUE(Attach(t, -1)); + const auto rgb = ReadRgb(Rgba8(), kEdge, kEdge); + Detach(); + ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, boxWins ? kBoxEdge : 0, boxWins ? kBoxEdge : 0); + } + + GLuint m_vao = 0, m_fbo = 0; + ColorFbo m_scratch{}; + std::vector m_programs; + std::vector m_textures; + }; + + // Control: the shadow's bytes go to the driver as they are. + TEST_F(PartialUploadOverGpuContentScenario, Rgba8SubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Srgb8SubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_SRGB8, GL_RGB, GL_UNSIGNED_BYTE, 3, Encoding::Unorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb8SnormSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB8_SNORM, GL_RGB, GL_BYTE, 3, Encoding::Snorm8}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb16fSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB16F, GL_RGB, GL_FLOAT, 3, Encoding::Float32}); + } + + TEST_F(PartialUploadOverGpuContentScenario, Rgb8uiSubImageKeepsTheGpuClear) { + if (!Ready()) return; + Run2D({GL_RGB8UI, GL_RGB_INTEGER, GL_UNSIGNED_BYTE, 3, Encoding::Uint8}); + } + + // A 1D array's ES image is a 2D array one texel high with the layers in depth, so the + // upload is shaped differently from the shadow (layers as rows) even with no conversion. + TEST_F(PartialUploadOverGpuContentScenario, OneDimensionalArraySubImageKeepsTheGpuClearedLayer) { + if (!Ready()) return; + constexpr int kLayers = 2; + const Format f{GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, 4, Encoding::Unorm8}; + const GLuint t = NewTexture(GL_TEXTURE_1D_ARRAY); + const auto red = Fill(f, kEdge * kLayers, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_1D_ARRAY, t); + glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_RGBA8, kEdge, kLayers, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data()); + glBindTexture(GL_TEXTURE_1D_ARRAY, 0); + ASSERT_EQ(FirstGLError(), 0u) << "definition"; + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + if (!Attach(t, 1)) { + Detach(); + GTEST_SKIP() << "a 1D array layer does not attach on backend " << Gl().BackendName(); + } + GpuClearGreen(f); + Detach(); + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + const auto blue = Fill(f, kBoxEdge, 0.0f, 0.0f, 1.0f); + glBindTexture(GL_TEXTURE_1D_ARRAY, t); + glTexSubImage2D(GL_TEXTURE_1D_ARRAY, 0, kBoxAt, 1, kBoxEdge, 1, GL_RGBA, GL_UNSIGNED_BYTE, blue.data()); + glBindTexture(GL_TEXTURE_1D_ARRAY, 0); + SyncBySampling(GL_TEXTURE_1D_ARRAY, t, "sampler1DArray", "ivec2(0)"); + ASSERT_TRUE(Attach(t, 1)); + const auto layer1 = ReadRgb(f, kEdge, 1); + Detach(); + ExpectClearAroundBox(layer1, kEdge, 1, kBoxAt, 0, kBoxEdge, 1); + } + + // The orders below matter to a backend that defers GPU work (Magma queues clears and runs + // texel uploads in a batch ahead of the frame's recorded commands); every one is plain GL. + + // The clear is still queued when the write arrives: it has to reach the level first. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClear) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + ClearRgba8Green(t); + WriteRgba8BlueBox(t); + SampleRgba8(t); + ExpectRgba8(t, true); + } + + // Same, with the readback as the first thing to sync the write. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterAQueuedClearLandsOnTheClearForAReadback) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + ClearRgba8Green(t); + WriteRgba8BlueBox(t); + ExpectRgba8(t, true); + } + + // The other order: the write is still pending when the clear arrives, and the clear wins. + TEST_F(PartialUploadOverGpuContentScenario, ClearAfterAPendingSubImageWins) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + WriteRgba8BlueBox(t); + ClearRgba8Green(t); + SampleRgba8(t); + ExpectRgba8(t, false); + } + + // A draw into the level instead of a clear. + TEST_F(PartialUploadOverGpuContentScenario, SubImageAfterADrawIntoTheLevelLandsOnTheDraw) { + if (!Ready()) return; + const GLuint t = NewRedRgba8(); + SampleRgba8(t); + DrawRgba8Green(t); + WriteRgba8BlueBox(t); + SampleRgba8(t); + ExpectRgba8(t, true); + } + } // namespace +} // namespace MGITest