[Fix] (DirectVulkan): bound image mutability so Adreno keeps UBWC compression

- Every storage-capable colour texture was created MUTABLE_FORMAT, and Adreno
  gives up bandwidth compression on an image that may be viewed as any format in
  its compatibility class. MC's main render target therefore ran uncompressed;
  in a fill-bound scene that is the whole frame budget. Measured on Adreno 650,
  MC 26.2, same scene and camera, device cooled to 38-40C before each run:
  65.3 -> 80.9 fps (+23.9%), GPU busy ~93% in both.
- VK_KHR_image_format_list (enabled when present) fixes it without giving up
  mutability: VkImageFormatListCreateInfo names the exact formats a view may
  use, so the driver can keep the image compressed. The set must be exhaustive
  or the result is undefined - for sampled views it is exactly what
  ResolveSampledImageViewFormat can return over the three numeric domains.
- glBindImageTexture may name any compatible format, which cannot be enumerated
  ahead of time, so a texture bound to an image unit gets no format list. That
  is what VK_IMAGE_USAGE_STORAGE_BIT becoming on-demand is for: it makes
  "unmarked" mean "will never receive an arbitrary-format storage view", which
  is what makes the list sound. Removing STORAGE is worth nothing on its own
  (65.4 fps, measured) - only the mutability bound pays.
- MarkStorageImageTexture runs over every collected image-unit texture before
  the probe loop in PrepareStorageImageTextures, because that loop stops at the
  first texture needing work and would leave the rest unmarked. The mark makes
  NeedsStorageImagePreparation report true, which is what ends the render pass,
  so the recreate lands outside it.
- storageUsageResolved separates "not upgraded yet" from "this format can never
  carry STORAGE", so a format whose optimalTilingFeatures lack STORAGE_IMAGE
  cannot ask for a recreate that will never happen. SyncTexture's cross-draw
  early-out also has to break on a pending upgrade or the recreate never runs.
- An upgrade recreates the image and carries its contents forward through
  PreserveTextureContentsOnRecreate, which submits its own command buffer and
  waits. Whatever the frame already recorded into the old image is still
  unsubmitted, so that copy would read pre-frame content and this frame's
  rendering into the texture would be lost - exactly the render-target-then-
  image-unit case. PrepareStorageImageTextures now flushes first; it takes the
  FrameData rather than a command buffer because the flush retires the current
  one, and drops the sampled-descriptor-set memo that described it.
This commit is contained in:
2026-07-29 07:07:50 -04:00
parent 992d16267c
commit fc4cd980f2
4 changed files with 204 additions and 24 deletions
@@ -2491,7 +2491,7 @@ void main() {
MOBILEGL_ASSERT(m_textureManager != nullptr, "VkTextureManager creation failed.");
succeeded = m_textureManager->Initialize(
{m_device, m_physicalDevice.handle, m_allocator, m_commandPool, m_graphicsQueue,
m_frameContext.GetFrameCount()});
m_frameContext.GetFrameCount(), m_imageFormatListExtensionEnabled});
MOBILEGL_ASSERT(succeeded, "VkTextureManager initialization failed.");
m_clearManager = MakeUnique<VkClearManager>();
MOBILEGL_ASSERT(m_clearManager != nullptr, "VkClearManager creation failed.");
@@ -4193,7 +4193,7 @@ void main() {
}
Bool VulkanRenderer::PrepareStorageImageTextures(
VkCommandBuffer commandBuffer,
FrameContext::FrameData& frame,
const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj) {
if (!programObj.hasStorageImages) {
@@ -4214,9 +4214,18 @@ void main() {
// keep the render pass alive instead of splitting it on every storage-image draw (on
// tiled GPUs each split is a full tile load/store). GL makes cross-draw image-store
// coherence the app's job (glMemoryBarrier), so no implicit barrier is owed here.
Bool anyNeedsPreparation = false;
// Record every image-unit binding before probing anything: a texture whose image was
// created without STORAGE usage (the default - it costs UBWC compression on Adreno)
// needs a recreate, and the probe below is what ends the render pass so that recreate
// lands here rather than mid-pass. This cannot be folded into the probe loop, which
// stops at the first texture that needs work and would leave the rest unmarked.
for (auto* texture : storageTextures) {
MOBILEGL_ASSERT(texture != nullptr, "%s: collected a null storage texture", __func__);
m_textureManager->MarkStorageImageTexture(*texture);
}
Bool anyNeedsPreparation = false;
for (auto* texture : storageTextures) {
if (m_textureManager->NeedsStorageImagePreparation(*texture) ||
m_clearManager->HasPendingClear(texture)) {
anyNeedsPreparation = true;
@@ -4227,21 +4236,51 @@ void main() {
return true;
}
// A first-time storage-usage upgrade recreates the image and carries the old contents
// forward with an out-of-band, immediately-submitted copy (PreserveTextureContentsOnRecreate).
// Whatever this frame already recorded into the old image is still sitting unsubmitted in
// this command buffer, so that copy would read pre-frame content and this frame's rendering
// into the texture would be lost - precisely the render-target-then-image-unit case this
// whole path exists for. Submit what is recorded first; the copy then queues behind it.
Bool anyNeedsStorageUpgrade = false;
for (auto* texture : storageTextures) {
if (m_textureManager->NeedsStorageUsageUpgrade(*texture)) {
anyNeedsStorageUpgrade = true;
break;
}
}
if (anyNeedsStorageUpgrade && HasPendingRecordedWork()) {
if (FlushPendingCommands()) {
// Fresh command buffer: the sampled-descriptor-set memo describes bindings that
// only existed in the retired one. FlushPendingCommands drops the pipeline memo
// itself; this is the other command-buffer-scoped cache.
m_lastSampledSetValid = false;
} else {
// Best effort: the upgrade still produces a correct image, only its preserved
// contents may predate this frame's writes. Dropping the draw would be worse.
MGLOG_E("%s: flush before a storage-usage image upgrade failed; preserved contents "
"may be stale for one frame", __func__);
}
}
if (!frame.isCommandRecording) {
m_frameContext.BeginCommandRecording();
}
// Image uploads, deferred-clear materialization, and layout barriers are illegal inside
// a classic render pass. Do this before sampler preparation as well: a texture used by
// both a sampler and an image must stay in GENERAL, and both descriptors must name that
// same layout independent of SPIR-V reflection/binding order.
if (VkRenderPassManager::GetActiveRenderPass() != nullptr) {
VkRenderPassManager::EndRenderPass(commandBuffer);
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
for (auto* texture : storageTextures) {
if (!MaterializePendingClearForTexture(commandBuffer, *texture)) {
if (!MaterializePendingClearForTexture(frame.commandBuffer, *texture)) {
MGLOG_E("%s: failed to materialize pending clear for storage textureId=%d",
__func__, texture->GetExternalIndex());
return false;
}
if (!m_textureManager->TransitionTextureForStorageImage(commandBuffer, *texture)) {
if (!m_textureManager->TransitionTextureForStorageImage(frame.commandBuffer, *texture)) {
MGLOG_E("%s: failed to prepare storage textureId=%d",
__func__, texture->GetExternalIndex());
return false;
@@ -4286,7 +4325,7 @@ void main() {
m_lastSampledSetValid = false;
}
if (!PrepareStorageImageTextures(frame.commandBuffer, program, programObj)) {
if (!PrepareStorageImageTextures(frame, program, programObj)) {
MGLOG_E("SetupDraw skipped: storage image preparation failed");
return false;
}
@@ -4511,7 +4550,7 @@ void main() {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
if (!PrepareStorageImageTextures(frame.commandBuffer, program, programObj)) {
if (!PrepareStorageImageTextures(frame, program, programObj)) {
MGLOG_E("DispatchCompute skipped: storage image preparation failed");
return;
}
@@ -4551,7 +4590,7 @@ void main() {
VkRenderPassManager::EndRenderPass(frame.commandBuffer);
}
if (!PrepareStorageImageTextures(frame.commandBuffer, program, programObj)) {
if (!PrepareStorageImageTextures(frame, program, programObj)) {
MGLOG_E("DispatchComputeIndirect skipped: storage image preparation failed");
return;
}
@@ -8157,6 +8196,18 @@ void main() {
const Vector<VkExtensionProperties> availableExtensions = EnumerateDeviceExtensions(m_physicalDevice.handle);
ResolveOptionalDeviceExtensions(availableExtensions, enabledDeviceExtensions);
// VK_KHR_image_format_list lets a MUTABLE_FORMAT image declare exactly which formats it
// may be viewed as. Adreno drops UBWC bandwidth compression on a blindly-mutable image
// (measured: 65 -> 80 fps in MC 26.2 once mutability is not requested); an explicit,
// compression-compatible format list is the portable way to keep both.
m_imageFormatListExtensionEnabled =
IsExtensionSupported(availableExtensions, VK_KHR_IMAGE_FORMAT_LIST_EXTENSION_NAME);
if (m_imageFormatListExtensionEnabled) {
enabledDeviceExtensions.push_back(VK_KHR_IMAGE_FORMAT_LIST_EXTENSION_NAME);
}
MGLOG_I("VK_KHR_image_format_list enabled: %s",
m_imageFormatListExtensionEnabled ? "true" : "false");
MGLOG_I("VK_KHR_draw_indirect_count enabled: %s", m_drawIndirectCountExtensionEnabled ? "true" : "false");
m_indexTypeUint8ExtensionEnabled = false;