[Perf] (Magma): converted wire vertex streams are cached across frames and default-framebuffer clears stay standalone (BSL Magma 19.5 -> 77.2 fps, MobileGlues 78.2).

This commit is contained in:
2026-10-08 06:59:39 -04:00
parent 4984da923f
commit 855b3b5009
13 changed files with 612 additions and 18 deletions
+11
View File
@@ -125,6 +125,17 @@ namespace MobileGL::MG_Config {
// (vkCmdClearAttachments) rather than in a render pass of its own built for the clear.
// Auto is ON; ForceOff restores the standalone clear for every surface.
QuirkOverride MagmaInPassClear = QuirkOverride::Auto;
// MOBILEGL_MAGMA_INPASS_CLEAR_DEFAULT: the same for the default framebuffer. Auto is OFF
// (an unexplained openra regression on Adreno 750, perf-p15/HANDOFF.md); ForceOn enables it.
QuirkOverride MagmaInPassClearDefault = QuirkOverride::Auto;
// MOBILEGL_MAGMA_PASS_CACHE: wire draw passes (render pass, framebuffer, views) are kept
// across frames while their key's image epochs hold. Auto is ON; ForceOff drops every
// cached pass whose last use completed at each frame begin, as before P15.
QuirkOverride MagmaPassCache = QuirkOverride::Auto;
// MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE: a wire vertex stream that needs a CPU format
// conversion is converted once per frame slot per (buffer content, layout) and reused by
// every draw that names it, instead of once per draw. Auto is ON; ForceOff converts per draw.
QuirkOverride MagmaVertexConversionCache = QuirkOverride::Auto;
// MOBILEGL_ADVERTISE_FP64: add GL_ARB_gpu_shader_fp64 to the advertised extension
// string. `double` in a shader always WORKS - it is narrowed to 32 bits before any
// module reaches a backend (ShaderTranspiler::DemoteFloat64Pass) - but the extension
+3
View File
@@ -346,6 +346,9 @@ namespace MobileGL::MG_ConfigLoader {
features.MagmaIterationRPFixBarrier = QueryEnvFlag("MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER");
features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS");
features.MagmaInPassClear = QueryEnvQuirkOverride("MOBILEGL_MAGMA_INPASS_CLEAR");
features.MagmaInPassClearDefault = QueryEnvQuirkOverride("MOBILEGL_MAGMA_INPASS_CLEAR_DEFAULT");
features.MagmaPassCache = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PASS_CACHE");
features.MagmaVertexConversionCache = QueryEnvQuirkOverride("MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE");
features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64");
features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK");
features.MagmaAllowInvalidSpirv = QueryEnvFlag("MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV");
@@ -108,7 +108,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = flags;
// P15: a frame recording is submitted once and then reset, so the driver may skip whatever
// it keeps for re-submission.
beginInfo.flags = flags | VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
beginInfo.pInheritanceInfo = pInheritanceInfo;
VK_VERIFY(vkBeginCommandBuffer(frame.commandBuffer, &beginInfo), "BeginCommandRecording, vkBeginCommandBuffer");
@@ -137,6 +139,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VK_VERIFY(vkResetCommandBuffer(frame.preCommandBuffer, 0), "BeginPreCommandRecording, vkResetCommandBuffer");
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK_VERIFY(vkBeginCommandBuffer(frame.preCommandBuffer, &beginInfo),
"BeginPreCommandRecording, vkBeginCommandBuffer");
frame.isPreCommandRecording = true;
@@ -225,6 +225,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// to wait for a submission (DeferredWireRelease), and three lines from now the record
// no longer carries it.
const Uint64 orphanedUseSerial = resource->lastUseSerial;
resource->contentSerial = ++m_wireContentSerial;
// P11 B2: a T0 store is ALWAYS released through the serial-gated list (never "at once"),
// so the client's AHardwareBuffer outlives every submission that can still name it however
// the client's own release (at this respecify) raced the frames in flight.
@@ -312,6 +313,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::abort();
}
if (size == 0) return;
resource->contentSerial = ++m_wireContentSerial;
Bool uploaded = false;
const Uint64 probeLastUse = resource->lastUseSerial;
const Uint64 probeCompleted = GetCompletedSerial();
@@ -417,12 +419,91 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// ordered after earlier uses. There is no stale shadow to replay here.
}
Bool VkBufferManager::LookupConvertedStream(const ConvertedStreamKey& key, SizeT elementCount,
BufferSlice& outSlice) {
for (auto& entry : m_convertedStreams) {
if (!(entry.key == key) || entry.elementCount < elementCount) continue;
entry.lastUseFrameSerial = m_frameSerial;
outSlice = entry.buffer.GetSlice();
outSlice.mapped = nullptr;
return true;
}
return false;
}
void VkBufferManager::RetireConvertedStream(SizeT index) {
auto& entry = m_convertedStreams[index];
m_convertedStreamBytes -= static_cast<SizeT>(entry.buffer.GetSize());
// Its last use may be this frame: the slot's next BeginFrame is after this frame completed.
if (m_currentFrameIndex < m_deferredBufferReleases.size())
m_deferredBufferReleases[m_currentFrameIndex].push_back(std::move(entry.buffer));
m_convertedStreams.erase(m_convertedStreams.begin() + static_cast<std::ptrdiff_t>(index));
}
Bool VkBufferManager::StoreConvertedStream(const ConvertedStreamKey& key, MG_Pipe::MGPipeHandle source,
SizeT elementCount, const void* bytes, SizeT size,
BufferSlice& outSlice) {
constexpr SizeT kConvertedStreamBudget = 192ull << 20;
if (size == 0 || size > kConvertedStreamBudget / 2 || m_initInfo.allocator == nullptr ||
m_deferredBufferReleases.empty())
return false;
// A newer conversion of the same store and layout supersedes the old one (its content
// serial can never come back); an entry unused for many frames (a deleted store's, a
// layout no longer drawn) goes too.
constexpr Uint64 kConvertedStreamIdleFrames = 256;
for (SizeT i = m_convertedStreams.size(); i-- > 0;) {
const auto& entry = m_convertedStreams[i];
if (entry.lastUseFrameSerial + kConvertedStreamIdleFrames < m_frameSerial) {
RetireConvertedStream(i);
continue;
}
if (entry.source == source && entry.key.baseOffset == key.baseOffset && entry.key.stride == key.stride &&
entry.key.type == key.type && entry.key.size == key.size && entry.key.conversion == key.conversion)
RetireConvertedStream(i);
}
while (!m_convertedStreams.empty() && m_convertedStreamBytes + size > kConvertedStreamBudget) {
SizeT oldest = 0;
for (SizeT i = 1; i < m_convertedStreams.size(); ++i)
if (m_convertedStreams[i].lastUseFrameSerial < m_convertedStreams[oldest].lastUseFrameSerial) oldest = i;
RetireConvertedStream(oldest);
}
ConvertedStream entry;
entry.key = key;
entry.source = source;
entry.elementCount = elementCount;
entry.lastUseFrameSerial = m_frameSerial;
if (!entry.buffer.Create({
.allocator = m_initInfo.allocator,
.size = static_cast<VkDeviceSize>(size),
.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
.memoryUsage = VMA_MEMORY_USAGE_AUTO,
.allocationFlags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT,
.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
}) || entry.buffer.Map() == nullptr || !entry.buffer.Upload(bytes, static_cast<VkDeviceSize>(size), 0))
return false;
m_convertedStreamBytes += size;
outSlice = entry.buffer.GetSlice();
outSlice.mapped = nullptr;
m_convertedStreams.push_back(std::move(entry));
return true;
}
Bool VkBufferManager::GetWireBufferContentSerial(MG_Pipe::MGPipeHandle res, Uint64& serial) {
auto* resource = FindWireBuffer(res);
if (!resource || resource->imported || !resource->buffer.IsValid()) return false;
serial = resource->contentSerial;
return true;
}
Bool VkBufferManager::AcquireWireSlice(BufferKind kind, MG_Pipe::MGPipeHandle res, BufferSlice& outSlice) {
(void)kind; // The store carries every buffer usage, so changing roles never orphans it.
// The store carries every buffer usage, so changing roles never orphans it.
outSlice = {};
auto* resource = FindWireBuffer(res);
if (!resource || !resource->buffer.IsValid() || resource->size == 0) return false;
resource->lastUseSerial = m_frameSerial;
// A slice a shader may write through: whatever it writes is a new content state.
if (kind == BufferKind::ShaderStorage || kind == BufferKind::TextureBuffer)
resource->contentSerial = ++m_wireContentSerial;
// B3: the draw this slice is being acquired for is normally recorded into the NEXT
// submission - normally, not always: a mid-draw flush (SyncWireTextureShape's preserve
// path) can submit this index without the draw. This stamp is the defence term's input
@@ -581,6 +662,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!resource || offset > resource->size) return;
if (size == MG_Pipe::kMGPipeWholeBuffer) size = resource->size - offset;
if (size == 0 || size > resource->size - offset) return;
resource->contentSerial = ++m_wireContentSerial;
resource->lastUseSerial = m_frameSerial;
resource->stagedCoverage.clear();
resource->gpuWritesPending = true;
@@ -721,6 +803,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void* mapped = resource->buffer.GetMappedData();
if (mapped == nullptr) return nullptr;
if (resource->imported) return mapped; // already donated: the same pointer
resource->contentSerial = ++m_wireContentSerial;
if (!WaitForWireBufferHostAccess(*resource)) return nullptr;
if (seedBytes != nullptr) std::memcpy(mapped, seedBytes, static_cast<SizeT>(size));
resource->imported = true;
@@ -1448,6 +1531,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void VkBufferManager::DestroyAllDeferredReleases() {
// Both callers (Shutdown, RecreateTransientArenas) have proven the device idle.
DestroyAllDeferredWireReleases();
for (auto& stream : m_convertedStreams) stream.buffer.Destroy();
m_convertedStreams.clear();
m_convertedStreamBytes = 0;
for (auto& releases : m_deferredBufferReleases) {
for (auto& buffer : releases) {
buffer.Destroy();
@@ -158,6 +158,28 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// that memo names can have been freed and re-minted in between, which is the one
// fact a handle-keyed memo of wire descriptors needs and cannot read off the handle.
Uint64 GetWireStoreDestroyEpoch() const { return m_wireStoreDestroyEpoch; }
// P15 S0: the store's content state, as a process-wide monotonic serial: it moves on every
// host write, respecify and GPU-write mark, and on every bind a shader may write through
// (storage / texture-buffer slices). False for a store whose bytes the client writes
// directly (an imported or donated store), which no cache may key on.
Bool GetWireBufferContentSerial(MG_Pipe::MGPipeHandle res, Uint64& serial);
// P15 S0: CONVERTED WIRE VERTEX STREAMS, KEPT ACROSS FRAMES (the frontend arm's
// ConvertedVertexStream cache, lost with it, and longer-lived than it: that one was per
// frame). Key: the source store's content serial - process-wide monotonic, so no store or
// handle identity can alias - and the layout. Each entry owns a vertex buffer holding the
// converted elements; a newer conversion of the same store and layout replaces it, and the
// whole set is bounded by bytes (least recently used first). A replaced or evicted buffer
// is released when the frame slot that last used it comes round again.
struct ConvertedStreamKey {
Uint64 contentSerial = 0;
Uint64 baseOffset = 0;
Uint32 stride = 0, type = 0, size = 0, conversion = 0;
Bool normalized = false, isInteger = false, isBgra = false;
Bool operator==(const ConvertedStreamKey&) const = default;
};
Bool LookupConvertedStream(const ConvertedStreamKey& key, SizeT elementCount, BufferSlice& outSlice);
Bool StoreConvertedStream(const ConvertedStreamKey& key, MG_Pipe::MGPipeHandle source, SizeT elementCount,
const void* bytes, SizeT size, BufferSlice& outSlice);
// Highest frame serial whose GPU work is known complete; serials at or
// below it may be considered signaled. Drives IsResourceBusy and the
// backend GL fence objects.
@@ -173,6 +195,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// unsubmitted index as incomplete, but the stamp is taken before the draw is
// recorded and a mid-draw flush can submit it without the draw.
Uint64 lastUseSubmitIndex = 0;
// P15 S0: see GetWireBufferContentSerial.
Uint64 contentSerial = 0;
Bool gpuWritesPending = false;
// Only ranges actually submitted by resource_subdata are covered. No
// shadow is retained: flush cannot replay stale bytes over GPU writes.
@@ -329,6 +353,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// so that a memo taken before a re-initialize must not match
// a handle minted after it.
Uint64 m_wireStoreDestroyEpoch = 0;
Uint64 m_wireContentSerial = 0;
struct ConvertedStream {
ConvertedStreamKey key;
MG_Pipe::MGPipeHandle source = MG_Pipe::kMGPipeNullHandle;
SizeT elementCount = 0;
VkBufferObject buffer;
Uint64 lastUseFrameSerial = 0;
};
Vector<ConvertedStream> m_convertedStreams;
SizeT m_convertedStreamBytes = 0;
void RetireConvertedStream(SizeT index);
Uint32 m_currentFrameIndex = 0;
Uint64 m_frameSerial = 1;
Uint64 m_completedSerialFloor = 0;
@@ -105,6 +105,25 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
Bool VkRenderPassManager::BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry,
VkRenderPass renderPass, const VkClearValue* clearValues,
Uint32 clearValueCount) {
VkRenderPassBeginInfo begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
begin.renderPass = renderPass;
begin.framebuffer = renderPassEntry.framebuffer;
begin.renderArea = {{0, 0}, {static_cast<Uint32>(renderPassEntry.extent.x()),
static_cast<Uint32>(renderPassEntry.extent.y())}};
begin.clearValueCount = clearValueCount;
begin.pClearValues = clearValues;
vkCmdBeginRenderPass(commandBuffer, &begin, VK_SUBPASS_CONTENTS_INLINE);
s_activeRenderPass.Get().commandBuffer = commandBuffer;
s_activeRenderPass.Get().hash = renderPassEntry.hash;
s_activeRenderPass.Get().compatibilityHash = renderPassEntry.compatibilityHash;
s_activeRenderPass.Get().extent = renderPassEntry.extent;
s_hasActiveRenderPass.Get() = true;
return true;
}
Bool VkRenderPassManager::EndRenderPass(VkCommandBuffer commandBuffer) {
BumpWireImageWriteEpoch();
vkCmdEndRenderPass(commandBuffer);
@@ -47,6 +47,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 layers = 1;
// Frame counter value of the last GetOrCreateRenderPass hit; drives cache eviction.
Uint64 lastUsedFrame = 0;
// P15 S0 (wire passes only): what the render pass was created from, and the render
// passes that differ from it only in loading some attachments with LOAD_OP_CLEAR. Load ops
// are not part of render-pass compatibility, so a variant begins with this entry's own
// framebuffer and runs every pipeline created against `renderPass`.
Vector<VkAttachmentDescription> wireAttachments;
Vector<VkAttachmentReference> wireColors;
VkAttachmentReference wireDepth{VK_ATTACHMENT_UNUSED, VK_IMAGE_LAYOUT_GENERAL};
Vector<std::pair<Uint64, VkRenderPass>> clearVariants;
RenderPassEntry() = default;
RenderPassEntry(const RenderPassEntry&) = delete;
@@ -62,6 +70,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::swap(extent, that.extent);
std::swap(layers, that.layers);
std::swap(lastUsedFrame, that.lastUsedFrame);
std::swap(wireAttachments, that.wireAttachments);
std::swap(wireColors, that.wireColors);
std::swap(wireDepth, that.wireDepth);
std::swap(clearVariants, that.clearVariants);
}
// Move ASSIGNMENT, not just construction. The move constructor above and the
// destructor below each independently suppress the implicit one, which left the
@@ -94,10 +106,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
std::swap(extent, that.extent);
std::swap(layers, that.layers);
std::swap(lastUsedFrame, that.lastUsedFrame);
std::swap(wireAttachments, that.wireAttachments);
std::swap(wireColors, that.wireColors);
std::swap(wireDepth, that.wireDepth);
std::swap(clearVariants, that.clearVariants);
}
return *this;
}
~RenderPassEntry() {
for (const auto& variant : clearVariants)
if (variant.second != VK_NULL_HANDLE) vkDestroyRenderPass(s_device.Get(), variant.second, nullptr);
if (renderPass != VK_NULL_HANDLE) {
vkDestroyRenderPass(s_device.Get(), renderPass, nullptr);
}
@@ -147,6 +165,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_defaultFramebufferTarget = serial;
}
static Bool BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry);
// P15 S0: begins `renderPassEntry` through `renderPass` (one of its clear-load variants)
// with these clear values; it is active as the entry itself, so a draw continues it.
static Bool BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry,
VkRenderPass renderPass, const VkClearValue* clearValues, Uint32 clearValueCount);
static Bool EndRenderPass(VkCommandBuffer commandBuffer);
static ActiveRenderPassInfo* GetActiveRenderPass();
private:
@@ -2371,6 +2371,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
WireDeclineTally::Dump("shutdown");
// The SPIR-V screen's engagement proof: a decline-free session above reads as "screened
// and valid" only with this count beside it (distinct modules, process-wide).
MGLOG_I("MGWIRE-VCONV[shutdown] converted=%llu reused=%llu", static_cast<unsigned long long>(m_wireStreamsConverted),
static_cast<unsigned long long>(m_wireStreamsReused));
MGLOG_I("MGWIRE-SPIRV-SCREEN[shutdown] modulesValidated=%llu",
static_cast<unsigned long long>(ProgramFactory::DriverModuleValidatorRuns()));
DestroyWireDrawPass();
@@ -880,6 +880,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool WirePassMemoHolds(const MG_Pipe::MGPFramebufferState& fbo, const FrameContext::FrameData& frame) const;
Uint64 m_wirePassMarksClearGeneration = 0;
WireDrawScratch m_wireDrawScratch;
Uint64 m_wireStreamsConverted = 0, m_wireStreamsReused = 0;
Bool PrepareWireDrawPass(FrameContext::FrameData& frame, const MG_Pipe::MGPFramebufferState& fbo,
WireDrawScratch& scratch, Bool passContinues, Bool& continuing);
// P15: glClear / glClearBuffer* into the attachments of the wire draw pass, recorded inside it
@@ -173,10 +173,14 @@
if (m_wireColorBlitResources) ResetWireColorBlitFramePool(frameIndex);
const Uint64 imageEpoch = m_textureManager->GetTextureImageEpoch();
const Uint64 eraseEpoch = m_textureManager->GetResourceEraseEpoch();
const Bool keepAcrossFrames = MG_Config::Features.MagmaPassCache != MG_Config::QuirkOverride::ForceOff;
SizeT kept = 0;
for (SizeT i = 0; i < m_wireDrawPassCache.size(); ++i) {
auto& entry = m_wireDrawPassCache[i];
if (entry.key.textureImageEpoch == imageEpoch && entry.key.resourceEraseEpoch == eraseEpoch) {
const Bool live = keepAcrossFrames ? entry.key.textureImageEpoch == imageEpoch &&
entry.key.resourceEraseEpoch == eraseEpoch
: entry.lastUseSubmit > m_completedSubmitCounter;
if (live) {
if (kept != i) m_wireDrawPassCache[kept] = Move(entry);
++kept;
continue;
@@ -426,6 +430,9 @@
created.extent = {static_cast<Int>(extent.width), static_cast<Int>(extent.height)};
created.layers = layers;
created.hash = created.compatibilityHash = XXH64(colors.data(), colors.size() * sizeof(colors[0]), passHash);
created.wireAttachments.assign(attachments.begin(), attachments.end());
created.wireColors.assign(colors.begin(), colors.end());
created.wireDepth = depth;
}
// The attachments' GPU-write marks (MarkWireTextureGpuWritten): idempotent while nothing
// clears a GPU-dirty mark, so a draw that continues a pass whose draws already marked the
@@ -641,6 +648,19 @@
MGL_WIRE_DECLINE_AT(VertexStreamCountZero, "the converted vertex stream would hold no elements");
return false;
}
// One conversion per (source content, layout), kept across frames until the source
// changes (VkBufferManager's converted-stream cache): a multi-draw over a chunk arena
// names the same stream for every chunk, frame after frame.
Uint64 contentSerial = 0;
const Bool cacheable = MG_Config::Features.MagmaVertexConversionCache != MG_Config::QuirkOverride::ForceOff &&
m_bufferManager.GetWireBufferContentSerial(resource.Res, contentSerial);
const VkBufferManager::ConvertedStreamKey cacheKey{contentSerial, offset, static_cast<Uint32>(stride),
static_cast<Uint32>(attr.Type), static_cast<Uint32>(attr.Size), static_cast<Uint32>(conversion),
attr.Normalized != 0, attr.IsInteger != 0, attr.IsBgra != 0};
if (cacheable && m_bufferManager.LookupConvertedStream(cacheKey, count, vertexSlices[binding])) {
++m_wireStreamsReused;
continue;
}
Vector<Uint8> source((count - 1) * stride + elementSize);
if (!m_bufferManager.ReadWireBuffer(resource.Res, offset, source.size(), source.data())) {
MGL_WIRE_DECLINE_AT(VertexStreamRead, "the vertex bytes to convert could not be read back");
@@ -664,7 +684,10 @@
WireDeclineTally::Count(WireDeclineSite::VertexStreamConverter);
return false;
}
if (!m_bufferManager.UploadTransient(BufferKind::Vertex, m_frameContext.GetCurrentFrameIndex(),
++m_wireStreamsConverted;
if (!(cacheable && m_bufferManager.StoreConvertedStream(cacheKey, resource.Res, count, converted.data(),
converted.size(), vertexSlices[binding])) &&
!m_bufferManager.UploadTransient(BufferKind::Vertex, m_frameContext.GetCurrentFrameIndex(),
converted.data(), converted.size(), 16, vertexSlices[binding])) {
MGL_WIRE_DECLINE_AT(VertexTransientUpload, "the converted vertex stream did not fit the transient arena");
return false;
@@ -865,29 +888,40 @@
return true;
}
// P15 S0: A CLEAR INTO THE OPEN PASS'S ATTACHMENTS IS RECORDED INSIDE THE PASS.
// P15 S0: A CLEAR INTO THE ATTACHMENTS OF THE DRAW PASS BECOMES PART OF THAT PASS.
//
// The standalone path (ClearWireFramebuffer's per-surface lambda) closes the draw pass, then
// builds an image view, a render pass and a framebuffer for every cleared surface, runs a pass
// of its own and retires all three: about 70 us a clear on Adreno 750, and every following draw
// to the same framebuffer opens a new pass (FCL MC 1.21.5: 6 clears a frame, 0.42 ms). Here the
// clear takes the pass a draw to this framebuffer would use (PrepareWireDrawPass: the open one,
// a cached one, or a new one through the same walk) and becomes a vkCmdClearAttachments in it,
// so the draws after it continue the same pass.
// a cached one, or a new one through the same walk), so the draws after it continue that pass:
//
// vkCmdClearAttachments inside the subpass is ordered with the pass's draws as a draw is, writes
// through the same attachment views (the UNORM twin of an sRGB image while GL_FRAMEBUFFER_SRGB
// is off, which is exactly GL's raw clear - no pre-compensation), and needs no layout change:
// wire attachments live in GENERAL. What it cannot express goes back to the standalone path,
// per GL buffer bit: a partial colour or stencil write mask (that path warns and skips, as
// before), an attachment whose extent or layer count differs from the pass's (the pass covers the
// common area only), and a stencil clear the pass's depth attachment does not carry.
// * A clear of the whole render area that would BEGIN the pass begins it with LOAD_OP_CLEAR for
// the cleared attachments (a clear-load variant of the pass's render pass). Nothing is loaded
// from memory into the tile for them, which is also the cheapest form on a tiler.
// * Any other clear - into the open pass, or scissored - is vkCmdClearAttachments in the pass:
// ordered with the pass's draws as a draw is.
//
// Every write goes through the pass's attachment views (the UNORM twin of an sRGB image while
// GL_FRAMEBUFFER_SRGB is off, exactly GL's raw clear - no pre-compensation), and wire attachments
// live in GENERAL. What none of this expresses goes back to the standalone path, per GL buffer
// bit: a partial colour or stencil write mask (that path warns and skips, as before), an
// attachment whose extent or layer count differs from the pass's (the pass covers the common area
// only), and a stencil clear the pass's depth attachment does not carry.
GLbitfield VulkanRenderer::ClearWireFramebufferInPass(const MG_Pipe::MGPFramebufferState& fbo,
const ClearAttachmentPayload& payload, GLint drawbuffer) {
GLbitfield remaining = payload.mask & (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
if (!remaining || MG_Config::Features.MagmaInPassClear == MG_Config::QuirkOverride::ForceOff) return remaining;
// Nested inside a draw's preparation the scratch and the pass belong to that draw.
if (m_wirePreparationDepth != 0) return remaining;
// NOT THE DEFAULT FRAMEBUFFER (measured, not understood): on Adreno 750 the openra retrace
// loses its palette-sampled sprites (SSIM 0.956) when a colour+depth clear of the default
// framebuffer becomes part of its draw pass - with LOAD_OP_CLEAR as well as with
// vkCmdClearAttachments - while the same clear split across the two paths, or left
// standalone, renders correctly. Default-framebuffer clears keep the standalone path until
// that is explained (perf-p15/HANDOFF.md).
if (fbo.IsDefault && MG_Config::Features.MagmaInPassClearDefault != MG_Config::QuirkOverride::ForceOn) return remaining;
auto& frame = m_frameContext.GetCurrent();
WirePreparationScope preparing(m_wirePreparationDepth);
// As SetupWireDraw: resolve every attachment (a lazy upload or promotion may submit the
@@ -992,16 +1026,66 @@
const Int32 x0 = std::max(box.x(), 0), y0 = std::max(box.y(), 0);
const Int32 x1 = std::min(box.x() + box.z(), static_cast<Int32>(key.width));
const Int32 y1 = std::min(box.y() + box.w(), static_cast<Int32>(key.height));
if (x0 >= x1 || y0 >= y1) return remaining & ~(handled | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT |
GL_STENCIL_BUFFER_BIT); // an empty box clears nothing
// An empty box clears nothing, on either path.
if (x0 >= x1 || y0 >= y1) return 0;
area = {{x0, y0}, {static_cast<Uint32>(x1 - x0), static_cast<Uint32>(y1 - y0)}};
if (fbo.IsDefault && !MapDefaultFramebufferReadbackRect(x0, y0, x1 - x0, y1 - y0, VkExtent2D{key.width, key.height},
m_swapchainObject.GetPreTransform(), &area.offset, &area.extent))
MagmaWireFatal("clear-surface-transform");
}
const Bool wholeArea = area.offset.x == 0 && area.offset.y == 0 && area.extent.width == key.width &&
area.extent.height == key.height;
const auto* active = VkRenderPassManager::GetActiveRenderPass();
const Bool open = continuing && active != nullptr && active->commandBuffer == frame.commandBuffer &&
active->hash == pass.hash;
Bool open = continuing && active != nullptr && active->commandBuffer == frame.commandBuffer &&
active->hash == pass.hash;
if (wholeArea && !open) {
if (active != nullptr) VkRenderPassManager::EndRenderPass(frame.commandBuffer);
auto& begun = *m_wireDrawPass;
Uint64 variantKey = 0;
VkClearValue values[MG_Pipe::kMGPipeMaxColorAttachments + 1]{};
const Uint32 attachmentCount = static_cast<Uint32>(begun.wireAttachments.size());
if (attachmentCount == 0 || attachmentCount > MG_Pipe::kMGPipeMaxColorAttachments + 1)
return remaining; // not a wire-built pass: cannot happen, but never guess
for (Uint32 c = 0; c < count; ++c) {
const auto& clear = clears[c];
if (clear.aspectMask & VK_IMAGE_ASPECT_COLOR_BIT) {
const Uint32 a = begun.wireColors[clear.colorAttachment].attachment;
variantKey |= 1ull << (2 * a);
values[a].color = clear.clearValue.color;
} else {
const Uint32 a = begun.wireDepth.attachment;
if (clear.aspectMask & VK_IMAGE_ASPECT_DEPTH_BIT) variantKey |= 1ull << (2 * a);
if (clear.aspectMask & VK_IMAGE_ASPECT_STENCIL_BIT) variantKey |= 1ull << (2 * a + 1);
values[a].depthStencil = clear.clearValue.depthStencil;
}
}
VkRenderPass variant = VK_NULL_HANDLE;
for (const auto& entry : begun.clearVariants)
if (entry.first == variantKey) variant = entry.second;
if (variant == VK_NULL_HANDLE) {
auto descriptions = begun.wireAttachments;
for (Uint32 a = 0; a < attachmentCount; ++a) {
if (variantKey >> (2 * a) & 1) descriptions[a].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
if (variantKey >> (2 * a + 1) & 1) descriptions[a].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
}
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = static_cast<Uint32>(begun.wireColors.size());
subpass.pColorAttachments = begun.wireColors.data();
subpass.pDepthStencilAttachment =
begun.wireDepth.attachment != VK_ATTACHMENT_UNUSED ? &begun.wireDepth : nullptr;
VkRenderPassCreateInfo info{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO};
info.attachmentCount = attachmentCount;
info.pAttachments = descriptions.data();
info.subpassCount = 1;
info.pSubpasses = &subpass;
VK_VERIFY(vkCreateRenderPass(m_device, &info, nullptr, &variant));
begun.clearVariants.push_back({variantKey, variant});
}
if (!VkRenderPassManager::BeginRenderPass(frame.commandBuffer, begun, variant, values, attachmentCount))
return remaining;
return remaining & ~handled;
}
if (!open && active != nullptr) VkRenderPassManager::EndRenderPass(frame.commandBuffer);
if (!open && !VkRenderPassManager::BeginRenderPass(frame.commandBuffer, pass)) return remaining;
const VkClearRect rect{area, 0, key.layers};
@@ -98,6 +98,8 @@ add_executable(MobileGLIntegrationTest
Scenarios/ClearThenReadPixelsScenario.cpp
# P15 S0: a clear between draws into the same framebuffer (DirectVulkan records it in the pass).
Scenarios/InPassClearScenario.cpp
# P15 S0: a CPU-converted vertex stream reused and re-written within a frame (DirectVulkan caches it).
Scenarios/ConvertedVertexStreamScenario.cpp
Scenarios/SampleVariablesScenario.cpp
Scenarios/DepthStencilReadbackScenario.cpp
Scenarios/DepthStencilReadbackMatrixScenario.cpp
@@ -2455,6 +2457,7 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
mgl_itest_register_split_arms("ClearThenReadPixelsScenario.*" "")
mgl_itest_register_split_arms("InPassClearScenario.*" "")
mgl_itest_register_split_arms("ConvertedVertexStreamScenario.*" "")
# GL_ARB_shader_objects (P13): ARB-created shader / program objects travel the record arm like
# core-created ones, so the scenario runs on every split arm as well as both monoliths.
mgl_itest_register_split_arms("ArbShaderObjectsScenario.*" "")
@@ -0,0 +1,261 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ConvertedVertexStreamScenario.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 VERTEX STREAM THE BACKEND CONVERTS ON THE CPU, REUSED AND RE-WRITTEN WITHIN A FRAME.
//
// P15 S0: DirectVulkan's wire arm converts a vertex stream whose layout the device cannot fetch
// (here a 16-bit attribute at an odd stride, which it repacks) once per (buffer content, layout)
// and reuses the converted copy for every draw that names it, frame after frame - a chunk
// renderer's multi-draws all name one arena. What that cache must get right:
//
// * REUSE: two indexed draws in one frame over different base vertices of one converted stream
// both read their own vertices out of the shared conversion.
// * INVALIDATION: a glBufferSubData of the source between two draws in the SAME frame is seen by
// the second draw (the store's content serial moved, so the cached conversion is not reused).
// * ACROSS FRAMES: an unchanged stream drawn over more frames than there are frame slots keeps
// drawing right, and a glBufferSubData between frames is seen by the next frame's draw.
//
// DirectGLES is the control; MOBILEGL_MAGMA_VERTEX_CONVERSION_CACHE=0 converts per draw.
#include <cstring>
#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 const char* kVS = R"(#version 330 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
uniform vec4 uColor;
out vec4 o0;
void main() { o0 = uColor; }
)";
constexpr int kSize = 64;
// 5-byte stride: the 4-byte (2 x GL_UNSIGNED_SHORT) attribute sits at odd offsets, which
// no device fetches directly, so Magma repacks the stream on the CPU.
constexpr int kStride = 5;
class ConvertedVertexStreamScenario : public ScenarioTest {};
// Writes vertex `index` of the interleaved stream: (x, y) in [0, 1] as normalized shorts.
void PutVertex(std::vector<unsigned char>& bytes, int index, float x, float y) {
const unsigned short sx = static_cast<unsigned short>(x * 65535.0f + 0.5f);
const unsigned short sy = static_cast<unsigned short>(y * 65535.0f + 0.5f);
std::memcpy(bytes.data() + index * kStride, &sx, 2);
std::memcpy(bytes.data() + index * kStride + 2, &sy, 2);
}
// Four vertices of an axis-aligned quad as a triangle strip.
void PutQuad(std::vector<unsigned char>& bytes, int first, float x0, float y0, float x1, float y1) {
PutVertex(bytes, first + 0, x0, y0);
PutVertex(bytes, first + 1, x1, y0);
PutVertex(bytes, first + 2, x0, y1);
PutVertex(bytes, first + 3, x1, y1);
}
::testing::AssertionResult Near(const Rgba8& got, int r, int g, int b) {
const auto close = [](int v, int want) { return v >= want - 3 && v <= want + 3; };
if (close(got.r, r) && close(got.g, g) && close(got.b, b)) return ::testing::AssertionSuccess();
return ::testing::AssertionFailure() << "got (" << int(got.r) << ", " << int(got.g) << ", " << int(got.b)
<< "), want (" << r << ", " << g << ", " << b << ")";
}
struct Target {
GLuint fbo = 0, color = 0;
};
Target MakeTarget() {
Target t;
glGenTextures(1, &t.color);
glBindTexture(GL_TEXTURE_2D, t.color);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
glGenFramebuffers(1, &t.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, t.fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, t.color, 0);
glViewport(0, 0, kSize, kSize);
glDisable(GL_DEPTH_TEST);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_BLEND);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
return t;
}
Rgba8 At(int x, int y) {
const Image image = ReadPixelsRect(x, y, 1, 1);
return image.At(0, 0);
}
} // namespace
TEST_F(ConvertedVertexStreamScenario, TwoIndexedDrawsShareOneConversionAndReadTheirOwnVertices) {
if (!Ready()) return;
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
Target target = MakeTarget();
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
std::vector<unsigned char> bytes(8 * kStride, 0);
PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half
PutQuad(bytes, 4, 0.5f, 0.0f, 1.0f, 1.0f); // right half
const unsigned short indices[] = {0, 1, 2, 3};
GLuint vao = 0, vbo = 0, ibo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes.size()), bytes.data(), GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr);
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glUseProgram(program);
const GLint color = glGetUniformLocation(program, "uColor");
glUniform4f(color, 1.0f, 0.0f, 0.0f, 1.0f);
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr, 0);
glUniform4f(color, 0.0f, 1.0f, 0.0f, 1.0f);
glDrawElementsBaseVertex(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr, 4);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, 0, 0)) << "left half: the first draw's vertices";
EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 255, 0)) << "right half: base vertex 4 of the same stream";
glDeleteBuffers(1, &ibo);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &target.fbo);
glDeleteTextures(1, &target.color);
glDeleteProgram(program);
}
TEST_F(ConvertedVertexStreamScenario, ASubDataBetweenTwoDrawsInOneFrameIsSeenByTheSecond) {
if (!Ready()) return;
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
Target target = MakeTarget();
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
std::vector<unsigned char> bytes(4 * kStride, 0);
PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half
const unsigned short indices[] = {0, 1, 2, 3};
GLuint vao = 0, vbo = 0, ibo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes.size()), bytes.data(), GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr);
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glUseProgram(program);
const GLint color = glGetUniformLocation(program, "uColor");
glUniform4f(color, 1.0f, 0.0f, 0.0f, 1.0f);
glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr);
// Same buffer, same layout, new bytes: the quad moves to the right half.
PutQuad(bytes, 0, 0.5f, 0.0f, 1.0f, 1.0f);
glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast<GLsizeiptr>(bytes.size()), bytes.data());
glUniform4f(color, 0.0f, 0.0f, 1.0f, 1.0f);
glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_SHORT, nullptr);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, 0, 0)) << "left half: the draw before the sub-data";
EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 255))
<< "right half: the draw after the sub-data must read the new bytes, not the cached conversion";
glDeleteBuffers(1, &ibo);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &target.fbo);
glDeleteTextures(1, &target.color);
glDeleteProgram(program);
}
TEST_F(ConvertedVertexStreamScenario, AConversionKeptAcrossFramesIsReplacedByASubDataBetweenFrames) {
if (!Ready()) return;
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
Target target = MakeTarget();
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
std::vector<unsigned char> bytes(4 * kStride, 0);
PutQuad(bytes, 0, 0.0f, 0.0f, 0.5f, 1.0f); // left half
GLuint vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes.size()), bytes.data(), GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_UNSIGNED_SHORT, GL_TRUE, kStride, nullptr);
glUseProgram(program);
const GLint color = glGetUniformLocation(program, "uColor");
// Several frames - more than any frame-slot count - drawing the one unchanged stream: every
// frame after the first reuses the conversion the first one made.
for (int frame = 0; frame < 5; ++frame) {
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
glViewport(0, 0, kSize, kSize);
glClear(GL_COLOR_BUFFER_BIT);
glUniform4f(color, 1.0f, static_cast<float>(frame) / 4.0f, 0.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 255, (frame * 255 + 2) / 4, 0)) << "frame " << frame;
EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 0)) << "frame " << frame;
Gl().EndFrame();
}
// New bytes between frames: the next frame's draw must not reuse the kept conversion.
PutQuad(bytes, 0, 0.5f, 0.0f, 1.0f, 1.0f);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, static_cast<GLsizeiptr>(bytes.size()), bytes.data());
for (int frame = 0; frame < 3; ++frame) {
glBindFramebuffer(GL_FRAMEBUFFER, target.fbo);
glViewport(0, 0, kSize, kSize);
glClear(GL_COLOR_BUFFER_BIT);
glUniform4f(color, 0.0f, 0.0f, 1.0f, 1.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
EXPECT_TRUE(Near(At(kSize / 4, kSize / 2), 0, 0, 0))
<< "frame " << frame << " after the sub-data: the old conversion was reused";
EXPECT_TRUE(Near(At(kSize * 3 / 4, kSize / 2), 0, 0, 255)) << "frame " << frame << " after the sub-data";
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), 0u);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &target.fbo);
glDeleteTextures(1, &target.color);
glDeleteProgram(program);
}
} // namespace MGITest
@@ -292,4 +292,68 @@ void main() { o0 = uColor; o1 = uColor; }
glDeleteTextures(1, &color);
}
// The openra shape on the DEFAULT framebuffer (P15 S0's open item, perf-p15/HANDOFF.md): every
// frame starts with glClear(COLOR | DEPTH), then re-specifies a palette texture
// (glTexImage2D - a recording flush on Magma) before the first draw samples it, and the
// sprites are depth-tested. Two frames, so the clear lands on a freshly acquired image.
TEST_F(InPassClearScenario, ADefaultFramebufferClearThenAPaletteUploadKeepsTheDepthTestedDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width(), height = gl.Height();
constexpr const char* kPaletteVS = R"(#version 330 core
in vec2 aPos;
uniform float uDepth;
void main() { gl_Position = vec4(aPos, uDepth, 1.0); }
)";
constexpr const char* kPaletteFS = R"(#version 330 core
uniform sampler2D uPalette;
out vec4 o0;
void main() { o0 = texture(uPalette, vec2(0.5, 0.5)); }
)";
std::string error;
const unsigned int paletteProgram = CompileProgram(kPaletteVS, kPaletteFS, &error);
ASSERT_NE(paletteProgram, 0u) << error;
Build();
GLuint palette = 0;
glGenTextures(1, &palette);
for (int frame = 0; frame < 3; ++frame) {
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_BLEND);
glDepthMask(GL_TRUE);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClearDepth(1.0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// The palette changes every frame; this frame's colour is what the sprite must show.
const unsigned char texel[4] = {static_cast<unsigned char>(frame == 2 ? 0 : 255), 255, 0, 255};
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, palette);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, texel);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// Terrain: no depth test, red.
glDisable(GL_DEPTH_TEST);
Quad(1.0f, 0.0f, 0.0f, 1.0f, 0.5f);
// Sprites: depth-tested against the cleared depth, palette-coloured.
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glUseProgram(paletteProgram);
glUniform1i(glGetUniformLocation(paletteProgram, "uPalette"), 0);
glUniform1f(glGetUniformLocation(paletteProgram, "uDepth"), 0.0f);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisable(GL_DEPTH_TEST);
EXPECT_EQ(FirstGLError(), 0u);
if (frame == 2) {
const Image image = ReadPixels(width, height);
EXPECT_TRUE(Near(image.At(width / 2, height / 2), 0, 255, 0, 255))
<< "frame 2: the depth-tested sprite shows this frame's palette (green)";
}
gl.EndFrame();
}
glDeleteTextures(1, &palette);
glDeleteProgram(paletteProgram);
}
} // namespace MGITest