From 1de7958ba22febd0cbf1a6b2fa12bd265c394da0 Mon Sep 17 00:00:00 2001 From: Sssnowyyy <78863999+Sssnowyyy@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:42:37 -0500 Subject: [PATCH 1/5] vulkan: back sparse terrain arrays at low detail without sparse residency Mario Kart World drew its world (road, cliffs, walls, trees) with a strong pink tint on macOS. The world's terrain materials come from three 8192x8192x68 sparse virtual texture arrays (two ASTC 8x8, 1.45 GB each, and one BC6H, 5.8 GB). The game streams into them through its sparse tile mappings: all 68 layers hold the low mip levels (1024x1024 and below, 0.4 MiB a layer in ASTC) and four layers hold full detail. The renderer never uploaded any of it on MoltenVK, because the upload for these arrays exists only for hosts with sparse residency (SparseLayers). They were created as sparse images, which MoltenVK accepts and then gives full Metal textures on first use (the arrays took about 9.9 GB, past the 16 GB of the Mac), and nothing was ever uploaded, so they sampled as undefined or zero texels, and terrain shaded from them came out pink. The same holds for a stand-in image of a smaller size: it makes no difference what size an array nobody uploads into has. A color array of 256 MiB or more in a Switch 2 sparse region, in a format the host samples natively, on a device without sparse residency (CompactLayers) is now backed with only its low mip levels, the first levels that fit 256 MiB (levels 2+ for the ASTC arrays, 3+ for the BC6H one: 90 MiB each), using the existing trimmed-level machinery (host_level_offset). Every layer is uploaded when the image is first used and only the written layers after that, reading just the levels the host keeps from guest memory (UnswizzleImage takes a first level). Normalized coordinates and derivative LOD address the trimmed image correctly; the four full-detail layers sample at 1/4 and 1/8 of their resolution. This needed a change in MemoryAllocator::CreateImage, which called an image "huge" (never backed) past 2^28 texels, whatever the format. It now counts bytes, with compressed formats at their size and everything else at 4 bytes a texel, so the limit for uncompressed images stays 1 GiB and a compressed image that fits is backed. Hosts with sparse residency are unaffected by the new path: they take the SparseLayers path, which creates these arrays unbacked. Tested with Mario Kart World on an M3 MacBook Air (16 GB), Hypervisor backend, Low GPU accuracy, key script through the menus into a race on Mario Bros. Circuit. Before, the free-roam island's road and walls were pink in the captures; after, the asphalt is grey and the desert terracotta in the title scenes and the race start (280 s, 36 frames, no pink). Process footprint at 240 s fell from 18 GB (21 GB physical) to 12 GB (16-17 GB physical), with the game reaching the race instead of still being in the menus. Not tested on NVIDIA or AMD, whose arrays do not go through the new path. Assisted-by: Claude Sonnet 5.5 (investigation, fix and commit message) --- .../renderer_vulkan/vk_texture_cache.cpp | 79 ++++++++++++++++++- src/video_core/texture_cache/image_base.h | 4 + src/video_core/texture_cache/texture_cache.h | 56 +++++++++++-- .../texture_cache/texture_cache_base.h | 3 + src/video_core/texture_cache/util.cpp | 11 +-- src/video_core/texture_cache/util.h | 4 +- .../vulkan_common/vulkan_memory_allocator.cpp | 42 +++++++++- 7 files changed, 180 insertions(+), 19 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index 6501a2c15b..e90ee3acda 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -204,6 +204,26 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { return layer_uploads && size >= (32U << 20) && Tegra::Nx2InSparseRegion(gpu_addr, size); } +// A huge natively sampled color array in a Switch 2 sparse region on a host that cannot leave +// layers unbound (MoltenVK): title 1's terrain arrays (8192x8192x68, 1.4 and 5.8 GB) hold full +// detail in a few layers and the low levels (1024x1024 and below) in all of them. Such an array +// is backed with its low levels only (see TrimmedHostInfo, Image::CompactLayers). +[[nodiscard]] bool UsesCompactLayers(const Device& device, const ImageInfo& info, + GPUVAddr gpu_addr) { + if (device.IsSparseResidencyImage2DSupported() || info.type != ImageType::e2D || + info.resources.layers <= 1 || info.resources.levels <= 1 || info.num_samples != 1 || + info.size.depth != 1 || + VideoCore::Surface::GetFormatType(info.format) != + VideoCore::Surface::SurfaceType::ColorTexture || + (IsPixelFormatASTC(info.format) && !device.IsOptimalAstcSupported()) || + (IsPixelFormatBCn(info.format) && !device.IsOptimalBcnSupported())) { + return false; + } + constexpr u32 MinSize = 256U << 20; + const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); + return size >= MinSize && Tegra::Nx2InSparseRegion(gpu_addr, size); +} + // How SparseTiles images leave the blocks without data: unbound (reads give zero), or all bound to // one zeroed block where the driver can't sample unbound memory (AMD) but lets blocks share // memory. EDEN_NX2_SPARSE_ALIASED=1 takes the shared block wherever it is supported, =0 never. @@ -350,12 +370,59 @@ constexpr std::array OpaqueAstcViewFormats{VK_FORMAT_BC1_RGBA_UNORM return device.GetDeviceLocalMemory() < (u64{7} << 30) ? (u64{256} << 20) : (u64{512} << 20); } +// The most a compact layered array keeps on the host: 256 MiB. EDEN_NX2_COMPACT_ARRAY_MIB= +// sets it. +[[nodiscard]] u64 CompactLayersLimit() { + static const u64 limit = [] { + const char* value = std::getenv("EDEN_NX2_COMPACT_ARRAY_MIB"); + const u64 mib = value != nullptr ? std::strtoull(value, nullptr, 10) : 0; + return (mib != 0 ? mib : 256) << 20; + }(); + return limit; +} + +// The host shape of a UsesCompactLayers array: the mip levels from `level_offset` on, the first +// level count that fits CompactLayersLimit. Normalized coordinates and derivative LOD still +// address it correctly. +[[nodiscard]] ImageInfo CompactHostInfo(const ImageInfo& info, u32& level_offset) { + const u64 block_bytes = VideoCore::Surface::BytesPerBlock(info.format); + const u32 block_width = VideoCore::Surface::DefaultBlockWidth(info.format); + const u32 block_height = VideoCore::Surface::DefaultBlockHeight(info.format); + const auto host_bytes = [&](u32 skip) { + u64 bytes = 0; + for (s32 level = static_cast(skip); level < info.resources.levels; ++level) { + bytes += u64{Common::DivCeil((std::max)(info.size.width >> level, 1U), block_width)} * + Common::DivCeil((std::max)(info.size.height >> level, 1U), block_height) * + block_bytes; + } + return bytes * info.resources.layers; + }; + while (level_offset + 1 < static_cast(info.resources.levels) && + host_bytes(level_offset) > CompactLayersLimit()) { + ++level_offset; + } + ImageInfo host = info; + host.size.width = (std::max)(info.size.width >> level_offset, 1U); + host.size.height = (std::max)(info.size.height >> level_offset, 1U); + host.resources.levels -= static_cast(level_offset); + LOG_WARNING(Render_Vulkan, + "{}x{}x{} array in a sparse region is {} MiB; keeping mip levels {}+ ({}x{}, " + "{} MiB)", + info.size.width, info.size.height, info.resources.layers, host_bytes(0) >> 20, + level_offset, host.size.width, host.size.height, host_bytes(level_offset) >> 20); + return host; +} + // The host shape of an image whose decoded copy would not fit in VRAM: a GPU-decoded ASTC array // over 1 GiB as RGBA8 (title 1's 8192x8192x68 ASTC 8x8 virtual textures are 24 GB each) keeps only // its mip levels from `level_offset` on, so host mip 0 is guest mip `level_offset`. Normalized // coordinates and derivative LOD still address it correctly; explicit LODs sample one level lower. -[[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, u32& level_offset) { +[[nodiscard]] ImageInfo TrimmedHostInfo(const Device& device, const ImageInfo& info, + u32& level_offset, GPUVAddr gpu_addr) { level_offset = 0; + if (UsesCompactLayers(device, info, gpu_addr)) { + return CompactHostInfo(info, level_offset); + } if (!WillUseAcceleratedAstcDecode(device, info) || info.type != ImageType::e2D) { return info; } @@ -2628,7 +2695,7 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu : VideoCommon::ImageBase(info_, gpu_addr_, cpu_addr_), scheduler{&runtime_.scheduler}, runtime{&runtime_}, original_image(MakeImage(runtime_.device, runtime_.memory_allocator, - TrimmedHostInfo(runtime_.device, info, host_level_offset), + TrimmedHostInfo(runtime_.device, info, host_level_offset, gpu_addr_), IsHugeAstcArray(runtime_.device, info) ? std::span{HugeAstcViewFormats} : IsOpaqueBc1Astc(runtime_.device, info) @@ -2643,7 +2710,8 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu aspect_mask(ImageAspectMask(info.format)) { if (original_image.IsUnbacked()) { flags |= VideoCommon::ImageFlagBits::Unbacked; - const ImageInfo host_info = TrimmedHostInfo(runtime_.device, info, host_level_offset); + const ImageInfo host_info = + TrimmedHostInfo(runtime_.device, info, host_level_offset, gpu_addr_); const u64 texels = u64{host_info.size.width} * host_info.size.height * host_info.size.depth * static_cast(host_info.resources.layers); if (UsesSparseLayers(runtime_.device, info, gpu_addr_) && InitSparseLayers()) { @@ -2658,6 +2726,9 @@ Image::Image(TextureCacheRuntime& runtime_, const ImageInfo& info_, GPUVAddr gpu } if (host_level_offset != 0) { flags |= VideoCommon::ImageFlagBits::TrimmedLevels; + if (UsesCompactLayers(runtime_.device, info, gpu_addr_) && !original_image.IsUnbacked()) { + flags |= VideoCommon::ImageFlagBits::CompactLayers; + } } if (IsHugeAstcArray(runtime->device, info)) { flags |= VideoCommon::ImageFlagBits::AcceleratedUpload; @@ -3141,7 +3212,7 @@ bool Image::InitSparseLayers() { s.requirements = device.GetLogical().GetImageMemoryRequirements(handle); s.sparse = *it; u32 offset = host_level_offset; - const ImageInfo host = TrimmedHostInfo(device, info, offset); + const ImageInfo host = TrimmedHostInfo(device, info, offset, gpu_addr); s.width = host.size.width; s.height = host.size.height; s.levels = static_cast(host.resources.levels); diff --git a/src/video_core/texture_cache/image_base.h b/src/video_core/texture_cache/image_base.h index 878c229953..7e738c41d6 100644 --- a/src/video_core/texture_cache/image_base.h +++ b/src/video_core/texture_cache/image_base.h @@ -60,6 +60,10 @@ enum class ImageFlagBits : u32 { /// device memory when the guest memory under it can hold data (Image::BindTiles), and only /// the parts written are uploaded. SparseTiles = 1 << 23, + /// A large array over Switch 2 sparse blocks on a host without sparse residency: backed, but + /// only its mip levels from host_level_offset on (the written parts of title 1's terrain + /// arrays are the low levels of every layer), uploaded layer by layer. + CompactLayers = 1 << 24, }; DECLARE_ENUM_FLAG_OPERATORS(ImageFlagBits) diff --git a/src/video_core/texture_cache/texture_cache.h b/src/video_core/texture_cache/texture_cache.h index 25a313dd22..c5eb12f5da 100644 --- a/src/video_core/texture_cache/texture_cache.h +++ b/src/video_core/texture_cache/texture_cache.h @@ -1666,6 +1666,10 @@ void TextureCache

::RefreshContents(Image& image, ImageId image_id) { RefreshSparseLayers(image); return; } + if (True(image.flags & ImageFlagBits::CompactLayers)) { + RefreshCompactLayers(image); + return; + } if (True(image.flags & ImageFlagBits::SparseTiles)) { RefreshSparseTiles(image); return; @@ -1769,7 +1773,7 @@ bool TextureCache

::UploadsByLayer(const ImageBase& image) const noexcept { // Title 1's terrain arrays (8192x8192x68 ASTC, 1.45 GB each) get a few layers streamed in at a // time; re-decoding all of them for each took a 2 GiB staging buffer and stalled the GPU. if constexpr (requires { &Runtime::AccelerateImageUploadLayers; }) { - if (True(image.flags & ImageFlagBits::SparseLayers)) { + if (True(image.flags & (ImageFlagBits::SparseLayers | ImageFlagBits::CompactLayers))) { return true; } return True(image.flags & ImageFlagBits::AcceleratedUpload) && @@ -1926,6 +1930,29 @@ void TextureCache

::RefreshSparseLayers(Image& image) { } } +template +void TextureCache

::RefreshCompactLayers(Image& image) { + if constexpr (requires { &Runtime::AccelerateImageUploadLayers; }) { + // A new image takes every layer (what the game never wrote reads as zero from guest + // memory, and the host image starts undefined); later only the layers written since. + const u32 num_layers = static_cast(image.info.resources.layers); + std::vector layers; + for (u32 layer = 0; layer < num_layers; ++layer) { + if (image.dirty_layers.empty() || + ((image.dirty_layers[layer / 64] >> (layer % 64)) & 1) != 0) { + layers.push_back(layer); + } + } + image.dirty_layers.clear(); + constexpr size_t LayersPerUpload = 16; + for (size_t first = 0; first < layers.size(); first += LayersPerUpload) { + const size_t count = (std::min)(LayersPerUpload, layers.size() - first); + UploadLayers(image, std::span{layers.data() + first, count}); + } + runtime.InsertUploadMemoryBarrier(); + } +} + template std::vector TextureCache

::BindSparseLayers(Image& image, std::span layers) { if constexpr (requires { &Image::BindLayers; }) { @@ -1945,20 +1972,32 @@ template void TextureCache

::UploadLayers(Image& image, std::span layers) { if constexpr (requires { &Runtime::AccelerateImageUploadLayers; }) { if (False(image.flags & ImageFlagBits::AcceleratedUpload)) { - // Plain formats: each layer unswizzled on its own into its layer of the image. + // Plain formats: each layer unswizzled on its own into its layer of the image. Of a + // trimmed image only the levels the host keeps are read. ImageInfo layer_info = image.info; layer_info.resources.layers = 1; - const size_t layer_size = CalculateUnswizzledSizeBytes(layer_info); + const s32 first_level = static_cast(image.host_level_offset); + size_t level_offset = 0; + ImageInfo host_info = layer_info; + if (first_level != 0) { + level_offset = FullUploadSwizzles(layer_info)[first_level].buffer_offset; + host_info.size.width = (std::max)(layer_info.size.width >> first_level, 1U); + host_info.size.height = (std::max)(layer_info.size.height >> first_level, 1U); + host_info.resources.levels -= first_level; + } + const size_t layer_size = CalculateUnswizzledSizeBytes(host_info); auto staging = runtime.UploadStagingBuffer(layer_size * layers.size()); std::vector copies; for (size_t i = 0; i < layers.size(); ++i) { - const GPUVAddr layer_addr = image.gpu_addr + u64{layers[i]} * image.info.layer_stride; + const GPUVAddr layer_addr = + image.gpu_addr + u64{layers[i]} * image.info.layer_stride + level_offset; Tegra::Memory::GpuGuestMemory - swizzle_data(*gpu_memory, layer_addr, image.info.layer_stride, + swizzle_data(*gpu_memory, layer_addr, image.info.layer_stride - level_offset, &swizzle_data_buffer); auto layer_copies = UnswizzleImage(*gpu_memory, layer_addr, layer_info, swizzle_data, - staging.mapped_span.subspan(i * layer_size, layer_size)); + staging.mapped_span.subspan(i * layer_size, layer_size), + first_level); for (BufferImageCopy copy : layer_copies) { copy.buffer_offset += i * layer_size; copy.image_subresource.base_layer = static_cast(layers[i]); @@ -1998,7 +2037,6 @@ template void TextureCache

::UploadImageContents(Image& image, StagingBuffer& staging) { const std::span mapped_span = staging.mapped_span; const GPUVAddr gpu_addr = image.gpu_addr; - if (True(image.flags & ImageFlagBits::AcceleratedUpload) && image.host_level_offset != 0) { // Only levels >= host_level_offset exist on the host: read each layer's tail of the mip // chain into a compact buffer (layer stride = that tail's size) and decode those levels. @@ -2225,6 +2263,10 @@ u64 TextureCache

::ImageMemoryBytes(const Image& image) const { return 0; } u64 tentative_size = (std::max)(image.guest_size_bytes, image.unswizzled_size_bytes); + if (True(image.flags & ImageFlagBits::CompactLayers)) { + // Each level is a quarter of the one before. + tentative_size >>= 2 * image.host_level_offset; + } if ((IsPixelFormatASTC(image.info.format) && True(image.flags & ImageFlagBits::AcceleratedUpload)) || True(image.flags & ImageFlagBits::Converted)) { diff --git a/src/video_core/texture_cache/texture_cache_base.h b/src/video_core/texture_cache/texture_cache_base.h index 06fcc0de77..cf44c0ac1c 100644 --- a/src/video_core/texture_cache/texture_cache_base.h +++ b/src/video_core/texture_cache/texture_cache_base.h @@ -192,6 +192,9 @@ class TextureCache : public VideoCommon::ChannelSetupCaches BindSparseLayers(Image& image, std::span layers); diff --git a/src/video_core/texture_cache/util.cpp b/src/video_core/texture_cache/util.cpp index 09e9513a5b..8756703c52 100644 --- a/src/video_core/texture_cache/util.cpp +++ b/src/video_core/texture_cache/util.cpp @@ -854,7 +854,8 @@ boost::container::small_vector UnswizzleImage(Tegra::Memory GPUVAddr gpu_addr, const ImageInfo& info, std::span input, - std::span output) { + std::span output, + s32 first_level) { const size_t guest_size_bytes = input.size_bytes(); const u32 bpp_log2 = BytesPerBlockLog2(info.format); const Extent2D tile_size = DefaultBlockSize(info.format); @@ -890,20 +891,20 @@ boost::container::small_vector UnswizzleImage(Tegra::Memory info.tile_width_spacing); size_t guest_offset = 0; u32 host_offset = 0; - boost::container::small_vector copies(num_levels); + boost::container::small_vector copies(num_levels - first_level); - for (s32 level = 0; level < num_levels; ++level) { + for (s32 level = first_level; level < num_levels; ++level) { const Extent3D level_size = AdjustMipSize(size, level); const u32 num_blocks_per_layer = NumBlocks(level_size, tile_size); const u32 host_bytes_per_layer = num_blocks_per_layer << bpp_log2; - copies[level] = BufferImageCopy{ + copies[level - first_level] = BufferImageCopy{ .buffer_offset = host_offset, .buffer_size = static_cast(host_bytes_per_layer) * num_layers, .buffer_row_length = Common::AlignUp(level_size.width, tile_size.width), .buffer_image_height = Common::AlignUp(level_size.height, tile_size.height), .image_subresource = { - .base_level = level, + .base_level = level - first_level, .base_layer = 0, .num_layers = info.resources.layers, }, diff --git a/src/video_core/texture_cache/util.h b/src/video_core/texture_cache/util.h index d91ee74c52..cef940f24b 100644 --- a/src/video_core/texture_cache/util.h +++ b/src/video_core/texture_cache/util.h @@ -99,9 +99,11 @@ struct UnswizzlePlan { void ExecuteUnswizzlePlan(Tegra::MemoryManager& gpu_memory, const UnswizzlePlan& plan, std::span output); +/// With `first_level` != 0, only the levels from there on are unswizzled; `input` then starts at +/// that level of the first layer, and the copies are numbered from 0. [[nodiscard]] boost::container::small_vector UnswizzleImage( Tegra::MemoryManager& gpu_memory, GPUVAddr gpu_addr, const ImageInfo& info, - std::span input, std::span output); + std::span input, std::span output, s32 first_level = 0); /// For a single-layer 2D block-linear image of 1x1-texel blocks: the host sparse blocks /// (tile_width x tile_height texels) that the guest byte ranges (offset, size) touch, merged into diff --git a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp index a0a39abe39..d01852adbf 100644 --- a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp +++ b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp @@ -16,6 +16,7 @@ #include "common/alignment.h" #include "common/assert.h" #include "common/common_types.h" +#include "common/div_ceil.h" #include "common/literals.h" #include "common/logging.h" #include @@ -85,6 +86,42 @@ namespace { } return VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE; } + + // Bits a texel of `format` takes, for sizing images: compressed formats at their rate, + // anything else at 32. + [[nodiscard]] u32 BitsPerTexel(VkFormat format) { + // Texels in an ASTC block, in format order (each shape has a UNORM and an SRGB format). + constexpr std::array AstcBlockTexels{16, 20, 25, 30, 36, 40, 48, + 64, 50, 60, 80, 100, 120, 144}; + const u32 value = static_cast(format); + if (value >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && value <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) { + const u32 texels = AstcBlockTexels[(value - VK_FORMAT_ASTC_4x4_UNORM_BLOCK) / 2]; + return Common::DivCeil(128U, texels); + } + switch (format) { + case VK_FORMAT_BC1_RGB_UNORM_BLOCK: + case VK_FORMAT_BC1_RGB_SRGB_BLOCK: + case VK_FORMAT_BC1_RGBA_UNORM_BLOCK: + case VK_FORMAT_BC1_RGBA_SRGB_BLOCK: + case VK_FORMAT_BC4_UNORM_BLOCK: + case VK_FORMAT_BC4_SNORM_BLOCK: + return 4; + case VK_FORMAT_BC2_UNORM_BLOCK: + case VK_FORMAT_BC2_SRGB_BLOCK: + case VK_FORMAT_BC3_UNORM_BLOCK: + case VK_FORMAT_BC3_SRGB_BLOCK: + case VK_FORMAT_BC5_UNORM_BLOCK: + case VK_FORMAT_BC5_SNORM_BLOCK: + case VK_FORMAT_BC6H_UFLOAT_BLOCK: + case VK_FORMAT_BC6H_SFLOAT_BLOCK: + case VK_FORMAT_BC7_UNORM_BLOCK: + case VK_FORMAT_BC7_SRGB_BLOCK: + return 8; + default: + return 32; + } + } + } // namespace //MemoryCommit is now VMA-backed @@ -229,9 +266,10 @@ vk::Image MemoryAllocator::CreateImage(const VkImageCreateInfo &ci, bool dedicat VmaAllocationInfo alloc_info{}; // Images this large (title 1's sparse texture arrays reach 4.5G texels, ~23 GB) are never // backed: VMA would quietly place them in system memory once VRAM is full. They take the - // unbacked sparse fallback below. + // unbacked sparse fallback below. 1 GiB, counted by texel size: a compressed image that + // fits is backed. const u64 texels = u64{ci.extent.width} * ci.extent.height * ci.extent.depth * ci.arrayLayers; - const bool huge = texels > (u64{1} << 28); + const bool huge = texels * BitsPerTexel(ci.format) > (u64{1} << 33); VkResult result = huge ? VK_ERROR_OUT_OF_DEVICE_MEMORY : vmaCreateImage(allocator, &ci, &alloc_ci, &handle, &allocation, &alloc_info); if (result == VK_ERROR_OUT_OF_DEVICE_MEMORY && !huge) { From 87ff48796d649bb97ab42afdc261eb4f4c9f911f Mon Sep 17 00:00:00 2001 From: Sssnowyyy <78863999+Sssnowyyy@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:58:57 -0500 Subject: [PATCH 2/5] vulkan: take the compact layer path where unbacked sparse images are unsafe AMD's drivers report sparse residency but do not survive sampling unbound memory, so CanUseUnbackedSparseImages() is false there and the terrain arrays could not take the SparseLayers path. UsesCompactLayers() only checked for sparse residency, so AMD missed it as well and the 5.8 GB BC6H array stayed unbacked. Gate CompactLayers on the complement of the SparseLayers device check instead, so a device that cannot leave these arrays unbound gets the backed low-detail copy. The two ASTC arrays are unchanged on hosts without native ASTC: they take the GPU decode path with trimmed levels. Reported by CloudP4bbs in review. Not tested on AMD. Assisted-by: Claude Opus 5.5 (implementation) --- src/video_core/renderer_vulkan/vk_texture_cache.cpp | 11 +++++++---- src/video_core/texture_cache/image_base.h | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index e90ee3acda..acab049e9c 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -205,12 +205,15 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { } // A huge natively sampled color array in a Switch 2 sparse region on a host that cannot leave -// layers unbound (MoltenVK): title 1's terrain arrays (8192x8192x68, 1.4 and 5.8 GB) hold full -// detail in a few layers and the low levels (1024x1024 and below) in all of them. Such an array -// is backed with its low levels only (see TrimmedHostInfo, Image::CompactLayers). +// layers unbound (MoltenVK, AMD): title 1's terrain arrays (8192x8192x68, 1.4 and 5.8 GB) hold +// full detail in a few layers and the low levels (1024x1024 and below) in all of them. Such an +// array is backed with its low levels only (see TrimmedHostInfo, Image::CompactLayers). [[nodiscard]] bool UsesCompactLayers(const Device& device, const ImageInfo& info, GPUVAddr gpu_addr) { - if (device.IsSparseResidencyImage2DSupported() || info.type != ImageType::e2D || + // The complement of UsesSparseLayers' device check: AMD has sparse residency but cannot + // sample unbound memory. + if ((device.IsSparseResidencyImage2DSupported() && device.CanUseUnbackedSparseImages()) || + info.type != ImageType::e2D || info.resources.layers <= 1 || info.resources.levels <= 1 || info.num_samples != 1 || info.size.depth != 1 || VideoCore::Surface::GetFormatType(info.format) != diff --git a/src/video_core/texture_cache/image_base.h b/src/video_core/texture_cache/image_base.h index 7e738c41d6..98945d7da5 100644 --- a/src/video_core/texture_cache/image_base.h +++ b/src/video_core/texture_cache/image_base.h @@ -60,8 +60,8 @@ enum class ImageFlagBits : u32 { /// device memory when the guest memory under it can hold data (Image::BindTiles), and only /// the parts written are uploaded. SparseTiles = 1 << 23, - /// A large array over Switch 2 sparse blocks on a host without sparse residency: backed, but - /// only its mip levels from host_level_offset on (the written parts of title 1's terrain + /// A large array over Switch 2 sparse blocks on a host that cannot leave it unbound: backed, + /// but only its mip levels from host_level_offset on (the written parts of title 1's terrain /// arrays are the low levels of every layer), uploaded layer by layer. CompactLayers = 1 << 24, }; From bd8e26b8afe7d37e1ffc371156d62211dbc4faca Mon Sep 17 00:00:00 2001 From: Sssnowyyy <78863999+Sssnowyyy@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:36:04 -0500 Subject: [PATCH 3/5] vulkan: clamp ImageView::MakeView to the levels a trimmed image keeps Sampled views of an image that keeps only its mip levels from host_level_offset on already map their guest levels onto the host image (HostRange). Views made through ImageView::MakeView (the typeless storage view and the depth, stencil and color views blits use) took the guest range as is, so on a trimmed image they could name levels the host image does not have. They now go through HostRange too. Reported by CodeRabbit. Assisted-by: Claude Opus 5.5 (implementation) --- .../renderer_vulkan/vk_texture_cache.cpp | 18 +++++++++++++----- .../renderer_vulkan/vk_texture_cache.h | 2 ++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index acab049e9c..b72bb840f5 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -829,13 +829,15 @@ TransformBufferCopies(std::span copies, size_t bu }; } -// A guest view's levels on a host image that keeps only levels >= host_level_offset. -[[nodiscard]] SubresourceRange HostRange(const Image& image, SubresourceRange range) { - const s32 skip = static_cast(image.host_level_offset); +// A guest view's levels on a host image of `levels` guest levels that keeps only levels >= +// host_level_offset. +[[nodiscard]] SubresourceRange HostRange(u32 host_level_offset, s32 levels, + SubresourceRange range) { + const s32 skip = static_cast(host_level_offset); if (skip == 0) { return range; } - const s32 host_levels = image.info.resources.levels - skip; + const s32 host_levels = levels - skip; const s32 base = std::clamp(range.base.level - skip, 0, host_levels - 1); const s32 end = std::clamp(range.base.level + range.extent.levels - skip, base + 1, host_levels); range.base.level = base; @@ -843,6 +845,10 @@ TransformBufferCopies(std::span copies, size_t bu return range; } +[[nodiscard]] SubresourceRange HostRange(const Image& image, SubresourceRange range) { + return HostRange(image.host_level_offset, image.info.resources.levels, range); +} + [[nodiscard]] VkImageSubresourceRange MakeSubresourceRange(const ImageView* image_view) { SubresourceRange range = image_view->range; if (True(image_view->flags & VideoCommon::ImageViewFlagBits::Slice)) { @@ -3983,6 +3989,7 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI ImageId image_id_, Image& image) : VideoCommon::ImageViewBase{info, image.info, image_id_, image.gpu_addr}, device{&runtime.device}, image_handle{image.Handle()}, + host_level_offset{image.host_level_offset}, image_levels{image.info.resources.levels}, samples(ConvertSampleCount(image.info.num_samples)) { using Shader::TextureType; @@ -4199,7 +4206,8 @@ bool ImageView::IsRescaled() const noexcept { vk::ImageView ImageView::MakeView(VkFormat vk_format, VkImageAspectFlags aspect_mask, std::optional texture_type) { VkImageViewType view_type = ImageViewType(type); - VkImageSubresourceRange subresource_range = MakeSubresourceRange(aspect_mask, range); + VkImageSubresourceRange subresource_range = + MakeSubresourceRange(aspect_mask, HostRange(host_level_offset, image_levels, range)); const auto is_3d = [](VkImageViewType t) { return t == VK_IMAGE_VIEW_TYPE_3D; }; if (texture_type && is_3d(ImageViewType(*texture_type)) != is_3d(view_type)) { // A 3D view of a 2D image (or the reverse) is invalid in Vulkan and aborts on Metal; keep diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.h b/src/video_core/renderer_vulkan/vk_texture_cache.h index 26081a4c72..9852a9f96e 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.h +++ b/src/video_core/renderer_vulkan/vk_texture_cache.h @@ -730,6 +730,8 @@ class ImageView : public VideoCommon::ImageViewBase { vk::Image null_image; VkImage image_handle = VK_NULL_HANDLE; VkImageView render_target = VK_NULL_HANDLE; + u32 host_level_offset = 0; // the image's (see Image::host_level_offset) + s32 image_levels = 0; VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT; u32 buffer_size = 0; From c2ec2cc6df8785c1709343d6d64ba67125ccd122 Mon Sep 17 00:00:00 2001 From: Sssnowyyy <78863999+Sssnowyyy@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:36:04 -0500 Subject: [PATCH 4/5] vulkan_memory_allocator: size huge images by their compressed blocks The huge-image check counted ASTC at a whole number of bits a texel, rounded up (10x10 at 2 bits instead of 1.28, 12x12 at 1 instead of 0.89), so some ASTC images that fit 1 GiB were never backed. Count the first level's bytes from its blocks instead: ASTC and BCn by block shape and block size, anything else at 4 bytes a texel as before. Reported by CodeRabbit. Assisted-by: Claude Opus 5.5 (implementation) --- .../vulkan_common/vulkan_memory_allocator.cpp | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp index d01852adbf..d99ea78a80 100644 --- a/src/video_core/vulkan_common/vulkan_memory_allocator.cpp +++ b/src/video_core/vulkan_common/vulkan_memory_allocator.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -87,25 +88,33 @@ namespace { return VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE; } - // Bits a texel of `format` takes, for sizing images: compressed formats at their rate, - // anything else at 32. - [[nodiscard]] u32 BitsPerTexel(VkFormat format) { - // Texels in an ASTC block, in format order (each shape has a UNORM and an SRGB format). - constexpr std::array AstcBlockTexels{16, 20, 25, 30, 36, 40, 48, - 64, 50, 60, 80, 100, 120, 144}; - const u32 value = static_cast(format); + // The bytes an image's first level takes over all its layers, for sizing: compressed formats + // by their blocks, anything else at 4 bytes a texel. + [[nodiscard]] u64 LevelZeroBytes(const VkImageCreateInfo& ci) { + // ASTC block shapes, in format order (each shape has a UNORM and an SRGB format). + constexpr std::array, 14> AstcBlocks{{{4, 4}, {5, 4}, {5, 5}, {6, 5}, + {6, 6}, {8, 5}, {8, 6}, {8, 8}, + {10, 5}, {10, 6}, {10, 8}, + {10, 10}, {12, 10}, {12, 12}}}; + u32 block_width = 1; + u32 block_height = 1; + u64 block_bytes = 4; + const u32 value = static_cast(ci.format); if (value >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && value <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) { - const u32 texels = AstcBlockTexels[(value - VK_FORMAT_ASTC_4x4_UNORM_BLOCK) / 2]; - return Common::DivCeil(128U, texels); - } - switch (format) { + std::tie(block_width, block_height) = + AstcBlocks[(value - VK_FORMAT_ASTC_4x4_UNORM_BLOCK) / 2]; + block_bytes = 16; + } else { + switch (ci.format) { case VK_FORMAT_BC1_RGB_UNORM_BLOCK: case VK_FORMAT_BC1_RGB_SRGB_BLOCK: case VK_FORMAT_BC1_RGBA_UNORM_BLOCK: case VK_FORMAT_BC1_RGBA_SRGB_BLOCK: case VK_FORMAT_BC4_UNORM_BLOCK: case VK_FORMAT_BC4_SNORM_BLOCK: - return 4; + block_width = block_height = 4; + block_bytes = 8; + break; case VK_FORMAT_BC2_UNORM_BLOCK: case VK_FORMAT_BC2_SRGB_BLOCK: case VK_FORMAT_BC3_UNORM_BLOCK: @@ -116,10 +125,16 @@ namespace { case VK_FORMAT_BC6H_SFLOAT_BLOCK: case VK_FORMAT_BC7_UNORM_BLOCK: case VK_FORMAT_BC7_SRGB_BLOCK: - return 8; + block_width = block_height = 4; + block_bytes = 16; + break; default: - return 32; + break; + } } + return u64{Common::DivCeil(ci.extent.width, block_width)} * + Common::DivCeil(ci.extent.height, block_height) * ci.extent.depth * ci.arrayLayers * + block_bytes; } } // namespace @@ -266,10 +281,9 @@ vk::Image MemoryAllocator::CreateImage(const VkImageCreateInfo &ci, bool dedicat VmaAllocationInfo alloc_info{}; // Images this large (title 1's sparse texture arrays reach 4.5G texels, ~23 GB) are never // backed: VMA would quietly place them in system memory once VRAM is full. They take the - // unbacked sparse fallback below. 1 GiB, counted by texel size: a compressed image that - // fits is backed. - const u64 texels = u64{ci.extent.width} * ci.extent.height * ci.extent.depth * ci.arrayLayers; - const bool huge = texels * BitsPerTexel(ci.format) > (u64{1} << 33); + // unbacked sparse fallback below. 1 GiB, counted by its blocks: a compressed image that fits + // is backed. + const bool huge = LevelZeroBytes(ci) > (u64{1} << 30); VkResult result = huge ? VK_ERROR_OUT_OF_DEVICE_MEMORY : vmaCreateImage(allocator, &ci, &alloc_ci, &handle, &allocation, &alloc_info); if (result == VK_ERROR_OUT_OF_DEVICE_MEMORY && !huge) { From cb113ac72b72100c980c84d55539462b1b0c70b3 Mon Sep 17 00:00:00 2001 From: Sssnowyyy <78863999+Sssnowyyy@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:37:11 -0500 Subject: [PATCH 5/5] texture_cache: count an image's guest size in 64 bits CalculateGuestSizeInBytes multiplied the layer stride by the layer count in 32 bits and ImageBase::guest_size_bytes stored it in 32 bits. Title 1's 8192x8192x68 BC6H terrain array is 5.8 GB, so its size wrapped to about 1.5 GB: the image was registered, and its CPU writes tracked, for only its first 17 or so layers, and writes to the later layers never marked them dirty for the compact (or sparse) layer uploads. Both are 64-bit now; everything they feed already takes a u64 or size_t. Reported by CodeRabbit. Assisted-by: Claude Opus 5.5 (implementation) --- src/video_core/renderer_opengl/gl_texture_cache.cpp | 2 +- src/video_core/renderer_vulkan/vk_texture_cache.cpp | 10 +++++----- src/video_core/texture_cache/image_base.h | 2 +- src/video_core/texture_cache/util.cpp | 7 ++++--- src/video_core/texture_cache/util.h | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/video_core/renderer_opengl/gl_texture_cache.cpp b/src/video_core/renderer_opengl/gl_texture_cache.cpp index 8cca26aee9..0c57b14fad 100644 --- a/src/video_core/renderer_opengl/gl_texture_cache.cpp +++ b/src/video_core/renderer_opengl/gl_texture_cache.cpp @@ -1200,7 +1200,7 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI ImageView::ImageView(TextureCacheRuntime&, const VideoCommon::ImageInfo& info, const VideoCommon::ImageViewInfo& view_info, GPUVAddr gpu_addr_) : VideoCommon::ImageViewBase{info, view_info, gpu_addr_}, - buffer_size{VideoCommon::CalculateGuestSizeInBytes(info)} {} + buffer_size{static_cast(VideoCommon::CalculateGuestSizeInBytes(info))} {} ImageView::ImageView(TextureCacheRuntime&, const VideoCommon::ImageInfo& info, const VideoCommon::ImageViewInfo& view_info) diff --git a/src/video_core/renderer_vulkan/vk_texture_cache.cpp b/src/video_core/renderer_vulkan/vk_texture_cache.cpp index b72bb840f5..b1306e07f7 100644 --- a/src/video_core/renderer_vulkan/vk_texture_cache.cpp +++ b/src/video_core/renderer_vulkan/vk_texture_cache.cpp @@ -180,7 +180,7 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { return false; } constexpr u32 MinSize = 32U << 20; - const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); + const u64 size = VideoCommon::CalculateGuestSizeInBytes(info); return size >= MinSize && info.type != ImageType::e1D && info.type != ImageType::Buffer && info.type != ImageType::Linear && Tegra::Nx2SparseUnwritten(gpu_addr, size); } @@ -200,7 +200,7 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { IsHugeAstcArray(device, info) || (!IsPixelFormatASTC(info.format) && (!IsPixelFormatBCn(info.format) || device.IsOptimalBcnSupported())); - const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); + const u64 size = VideoCommon::CalculateGuestSizeInBytes(info); return layer_uploads && size >= (32U << 20) && Tegra::Nx2InSparseRegion(gpu_addr, size); } @@ -223,7 +223,7 @@ constexpr VkBorderColor ConvertBorderColor(const std::array& color) { return false; } constexpr u32 MinSize = 256U << 20; - const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); + const u64 size = VideoCommon::CalculateGuestSizeInBytes(info); return size >= MinSize && Tegra::Nx2InSparseRegion(gpu_addr, size); } @@ -266,7 +266,7 @@ enum class SparseTilesMode { VideoCore::Surface::GetFormatType(info.format) != VideoCore::Surface::SurfaceType::ColorTexture) { return false; } - const u32 size = VideoCommon::CalculateGuestSizeInBytes(info); + const u64 size = VideoCommon::CalculateGuestSizeInBytes(info); return size >= (32U << 20) && Tegra::Nx2InSparseRegion(gpu_addr, size); } @@ -4120,7 +4120,7 @@ ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageViewI ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::ImageInfo& info, const VideoCommon::ImageViewInfo& view_info, GPUVAddr gpu_addr_) : VideoCommon::ImageViewBase{info, view_info, gpu_addr_}, - buffer_size{VideoCommon::CalculateGuestSizeInBytes(info)} {} + buffer_size{static_cast(VideoCommon::CalculateGuestSizeInBytes(info))} {} ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::NullImageViewParams& params) : VideoCommon::ImageViewBase{params}, device{&runtime.device} { diff --git a/src/video_core/texture_cache/image_base.h b/src/video_core/texture_cache/image_base.h index 98945d7da5..33283790ac 100644 --- a/src/video_core/texture_cache/image_base.h +++ b/src/video_core/texture_cache/image_base.h @@ -108,7 +108,7 @@ struct ImageBase { ImageInfo info; - u32 guest_size_bytes = 0; + u64 guest_size_bytes = 0; u32 unswizzled_size_bytes = 0; u32 converted_size_bytes = 0; u32 scale_rating = 0; diff --git a/src/video_core/texture_cache/util.cpp b/src/video_core/texture_cache/util.cpp index 8756703c52..3fa6d6ab2d 100644 --- a/src/video_core/texture_cache/util.cpp +++ b/src/video_core/texture_cache/util.cpp @@ -578,7 +578,7 @@ void SwizzleBlockLinearImage(Tegra::MemoryManager& gpu_memory, GPUVAddr gpu_addr } // Anonymous namespace -u32 CalculateGuestSizeInBytes(const ImageInfo& info) noexcept { +u64 CalculateGuestSizeInBytes(const ImageInfo& info) noexcept { if (info.type == ImageType::Buffer) { return info.size.width * BytesPerBlock(info.format); } @@ -586,8 +586,9 @@ u32 CalculateGuestSizeInBytes(const ImageInfo& info) noexcept { return info.pitch * Common::DivCeil(info.size.height, DefaultBlockHeight(info.format)); } if (info.resources.layers > 1) { + // 64-bit: title 1's 8192x8192x68 BC6H terrain array is 5.8 GB. ASSERT(info.layer_stride != 0); - return info.layer_stride * info.resources.layers; + return u64{info.layer_stride} * static_cast(info.resources.layers); } else { return CalculateLayerSize(info); } @@ -1684,7 +1685,7 @@ u32 MapSizeBytes(const ImageBase& image) { return tail * image.info.resources.layers; } if (True(image.flags & ImageFlagBits::AcceleratedUpload)) { - return image.guest_size_bytes; + return static_cast(image.guest_size_bytes); } else if (True(image.flags & ImageFlagBits::Converted)) { return image.converted_size_bytes; } else { diff --git a/src/video_core/texture_cache/util.h b/src/video_core/texture_cache/util.h index cef940f24b..2990ae1741 100644 --- a/src/video_core/texture_cache/util.h +++ b/src/video_core/texture_cache/util.h @@ -32,7 +32,7 @@ struct OverlapResult { SubresourceExtent resources; }; -[[nodiscard]] u32 CalculateGuestSizeInBytes(const ImageInfo& info) noexcept; +[[nodiscard]] u64 CalculateGuestSizeInBytes(const ImageInfo& info) noexcept; [[nodiscard]] u32 CalculateUnswizzledSizeBytes(const ImageInfo& info) noexcept;