diff --git a/CMakeLists.txt b/CMakeLists.txt
index e73062c5..c24a89b7 100755
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,7 +33,7 @@ endif()
 
 option(ENABLE_QT_GUI "Enable the Qt GUI. If not selected then the emulator uses a minimal SDL-based UI instead" OFF)
 option(ENABLE_DISCORD_RPC "Enable the Discord RPC integration" ON)
-CMAKE_DEPENDENT_OPTION(ENABLE_USERFAULTFD "Enable write tracking using userfaultfd on unix" ON "NOT LINUX" OFF)
+CMAKE_DEPENDENT_OPTION(ENABLE_USERFAULTFD "Enable write tracking using userfaultfd on unix" ON "NOT LINUX OR APPLE" OFF)
 
 # First, determine whether to use CMAKE_OSX_ARCHITECTURES or CMAKE_SYSTEM_PROCESSOR.
 if (APPLE AND CMAKE_OSX_ARCHITECTURES)
diff --git a/src/video_core/amdgpu/liverpool.cpp b/src/video_core/amdgpu/liverpool.cpp
index 53aab630..a34c5db5 100644
--- a/src/video_core/amdgpu/liverpool.cpp
+++ b/src/video_core/amdgpu/liverpool.cpp
@@ -550,7 +550,7 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, std::span<c
                                            sizeof(u32), false);
                 } else if (dma_data->src_sel == DmaDataSrc::Gds &&
                            dma_data->dst_sel == DmaDataDst::Memory) {
-                    LOG_WARNING(Render_Vulkan, "GDS memory read");
+                    LOG_DEBUG(Render_Vulkan, "GDS memory read");
                 } else if (dma_data->src_sel == DmaDataSrc::Memory &&
                            dma_data->dst_sel == DmaDataDst::Memory) {
                     rasterizer->InlineData(dma_data->DstAddress<VAddr>(),
diff --git a/src/video_core/texture_cache/image.cpp b/src/video_core/texture_cache/image.cpp
index bea2ce4f..258e6d53 100644
--- a/src/video_core/texture_cache/image.cpp
+++ b/src/video_core/texture_cache/image.cpp
@@ -137,6 +137,9 @@ Image::Image(const Vulkan::Instance& instance_, Vulkan::Scheduler& scheduler_,
     : instance{&instance_}, scheduler{&scheduler_}, info{info_},
       image{instance->GetDevice(), instance->GetAllocator()}, cpu_addr{info.guest_address},
       cpu_addr_end{cpu_addr + info.guest_size_bytes} {
+    ASSERT(info.resources.layers * info.resources.levels <= 64);
+    subres_state =
+        std::numeric_limits<u64>::max() >> (64 - info.resources.levels * info.resources.layers);
     mip_hashes.resize(info.resources.levels);
     ASSERT(info.pixel_format != vk::Format::eUndefined);
     // Here we force `eExtendedUsage` as don't know all image usage cases beforehand. In normal case
diff --git a/src/video_core/texture_cache/image.h b/src/video_core/texture_cache/image.h
index 312ff97e..d0d94dc4 100644
--- a/src/video_core/texture_cache/image.h
+++ b/src/video_core/texture_cache/image.h
@@ -91,9 +91,24 @@ struct Image {
         return image_view_ids[std::distance(image_view_infos.begin(), it)];
     }
 
+    void ForEachSubresource(VAddr addr, size_t size, auto&& func) {
+        const u32 num_layers = info.resources.layers;
+        for (u32 m = 0; const auto& mip : info.mips_layout) {
+            for (u32 l = 0; l < num_layers; l++) {
+                const VAddr mip_addr = info.guest_address + mip.offset * num_layers + mip.size * l;
+                const VAddr mip_addr_end = mip_addr + mip.size;
+                if (mip_addr < addr + size && addr < mip_addr_end) {
+                    func(m * num_layers + l);
+                }
+            }
+            m++;
+        }
+    }
+
     boost::container::small_vector<vk::ImageMemoryBarrier2, 32> GetBarriers(
         vk::ImageLayout dst_layout, vk::Flags<vk::AccessFlagBits2> dst_mask,
         vk::PipelineStageFlags2 dst_stage, std::optional<SubresourceRange> subres_range);
+
     void Transit(vk::ImageLayout dst_layout, vk::Flags<vk::AccessFlagBits2> dst_mask,
                  std::optional<SubresourceRange> range, vk::CommandBuffer cmdbuf = {});
     void Upload(vk::Buffer buffer, u64 offset);
@@ -111,6 +126,7 @@ struct Image {
     VAddr cpu_addr_end = 0;
     std::vector<ImageViewInfo> image_view_infos;
     std::vector<ImageViewId> image_view_ids;
+    u64 subres_state{};
 
     // Resource state tracking
     vk::ImageUsageFlags usage;
diff --git a/src/video_core/texture_cache/texture_cache.cpp b/src/video_core/texture_cache/texture_cache.cpp
index 279e0d82..9a3dad60 100644
--- a/src/video_core/texture_cache/texture_cache.cpp
+++ b/src/video_core/texture_cache/texture_cache.cpp
@@ -46,8 +46,10 @@ TextureCache::~TextureCache() = default;
 void TextureCache::InvalidateMemory(VAddr address, size_t size) {
     std::scoped_lock lock{mutex};
     ForEachImageInRegion(address, size, [&](ImageId image_id, Image& image) {
-        // Ensure image is reuploaded when accessed again.
+        // Mark any subresources as dirty.
         image.flags |= ImageFlagBits::CpuDirty;
+        image.ForEachSubresource(address, size,
+                                 [&](u32 index) { image.subres_state |= 1ULL << index; });
         // Untrack image, so the range is unprotected and the guest can write freely.
         UntrackImage(image_id);
     });
@@ -57,12 +59,13 @@ void TextureCache::InvalidateMemoryFromGPU(VAddr address, size_t max_size) {
     std::scoped_lock lock{mutex};
     ForEachImageInRegion(address, max_size, [&](ImageId image_id, Image& image) {
         // Only consider images that match base address.
-        // TODO: Maybe also consider subresources
         if (image.info.guest_address != address) {
             return;
         }
-        // Ensure image is reuploaded when accessed again.
+        // Mark any subresources as dirty.
         image.flags |= ImageFlagBits::GpuDirty;
+        image.ForEachSubresource(address, max_size,
+                                 [&](u32 index) { image.subres_state |= 1ULL << index; });
     });
 }
 
@@ -375,12 +378,18 @@ void TextureCache::RefreshImage(Image& image, Vulkan::Scheduler* custom_schedule
         return;
     }
 
-    const auto& num_layers = image.info.resources.layers;
-    const auto& num_mips = image.info.resources.levels;
+    const u32 num_layers = image.info.resources.layers;
+    const u32 num_mips = image.info.resources.levels;
     ASSERT(num_mips == image.info.mips_layout.size());
 
     boost::container::small_vector<vk::BufferImageCopy, 14> image_copy{};
     for (u32 m = 0; m < num_mips; m++) {
+        const u32 mask = (1 << num_layers) - 1;
+        const u64 subres_state = (image.subres_state >> (m * num_layers)) & mask;
+        if (subres_state == 0) {
+            continue;
+        }
+
         const u32 width = std::max(image.info.size.width >> m, 1u);
         const u32 height = std::max(image.info.size.height >> m, 1u);
         const u32 depth =
@@ -399,19 +408,40 @@ void TextureCache::RefreshImage(Image& image, Vulkan::Scheduler* custom_schedule
             image.mip_hashes[m] = hash;
         }
 
-        image_copy.push_back({
-            .bufferOffset = mip_ofs * num_layers,
-            .bufferRowLength = static_cast<u32>(mip_pitch),
-            .bufferImageHeight = static_cast<u32>(mip_height),
-            .imageSubresource{
-                .aspectMask = image.aspect_mask & ~vk::ImageAspectFlagBits::eStencil,
-                .mipLevel = m,
-                .baseArrayLayer = 0,
-                .layerCount = num_layers,
-            },
-            .imageOffset = {0, 0, 0},
-            .imageExtent = {width, height, depth},
-        });
+        if (subres_state == mask) {
+            image_copy.push_back({
+                .bufferOffset = mip_ofs * num_layers,
+                .bufferRowLength = static_cast<u32>(mip_pitch),
+                .bufferImageHeight = static_cast<u32>(mip_height),
+                .imageSubresource{
+                    .aspectMask = image.aspect_mask & ~vk::ImageAspectFlagBits::eStencil,
+                    .mipLevel = m,
+                    .baseArrayLayer = 0,
+                    .layerCount = num_layers,
+                },
+                .imageOffset = {0, 0, 0},
+                .imageExtent = {width, height, depth},
+            });
+        } else {
+            for (u32 l = 0; l < num_layers; l++) {
+                if (!(subres_state & (1 << l))) {
+                    continue;
+                }
+                image_copy.push_back({
+                    .bufferOffset = mip_ofs * num_layers + mip_size * l,
+                    .bufferRowLength = static_cast<u32>(mip_pitch),
+                    .bufferImageHeight = static_cast<u32>(mip_height),
+                    .imageSubresource{
+                        .aspectMask = image.aspect_mask & ~vk::ImageAspectFlagBits::eStencil,
+                        .mipLevel = m,
+                        .baseArrayLayer = l,
+                        .layerCount = 1,
+                    },
+                    .imageOffset = {0, 0, 0},
+                    .imageExtent = {width, height, depth},
+                });
+            }
+        }
     }
 
     if (image_copy.empty()) {
@@ -447,6 +477,7 @@ void TextureCache::RefreshImage(Image& image, Vulkan::Scheduler* custom_schedule
 
     cmdbuf.copyBufferToImage(buffer, image.image, vk::ImageLayout::eTransferDstOptimal, image_copy);
     image.flags &= ~ImageFlagBits::Dirty;
+    image.subres_state = 0;
 }
 
 vk::Sampler TextureCache::GetSampler(const AmdGpu::Sampler& sampler) {