[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

This commit is contained in:
2026-09-29 04:28:55 -04:00
parent 8966d5c294
commit b1d60523e4
6 changed files with 623 additions and 7 deletions
@@ -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;
}
@@ -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
@@ -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<Uint32>(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<PendingClearEntry> 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(
@@ -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.
@@ -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
@@ -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 <cmath>
#include <cstdint>
#include <cstring>
#include <sstream>
#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 {
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<std::uint8_t> 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<std::uint8_t> out(static_cast<std::size_t>(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<std::size_t>(t) * f.components + c) * componentBytes;
const float v = rgba[c];
switch (f.encoding) {
case Encoding::Unorm8: *dst = static_cast<std::uint8_t>(std::lround(v * 255.0f)); break;
case Encoding::Snorm8:
*dst = static_cast<std::uint8_t>(static_cast<std::int8_t>(std::lround(v * 127.0f)));
break;
case Encoding::Float32: std::memcpy(dst, &v, sizeof(v)); break;
case Encoding::Uint8: *dst = static_cast<std::uint8_t>(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<GLsizei>(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<float> ReadRgb(const Format& f, int width, int height) {
std::vector<float> rgb(static_cast<std::size_t>(width * height) * 3u);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
if (f.encoding == Encoding::Uint8) {
std::vector<GLuint> texels(static_cast<std::size_t>(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<float>(texels[i * 4 + c]) / kIntegerOne;
} else {
std::vector<GLfloat> texels(static_cast<std::size_t>(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<float>& 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<std::size_t>(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<GLint>(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<GLuint> m_programs;
std::vector<GLuint> 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