From 810409bbec67097dcbe5405ab6cd2ae239d0e3c5 Mon Sep 17 00:00:00 2001 From: YuutaTsubasa <81959102+YuutaTsubasa@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:08:05 +0800 Subject: [PATCH] macOS: present on Retina and scaled displays (Issue #191) On a Retina or scaled display the game closed right after Start with "failed to present native swap-chain texture". The game window was not high DPI, so SDL's Metal layer had contents scale 1 (1280x720 pixels), while Plume sized the swap chain by the window's backing scale (2560x1440). MoltenVK holds a swap chain to the layer's own size and returned VK_SUBOPTIMAL_KHR for every present; Plume treats that as a failure on Apple, and after 8 rebuilds at the same wrong size the game gave up. Reproduced on the M1 with 0.10.14 (through the launcher too) on a 1280x720 HiDPI display mode; 1x displays were never affected. - The macOS game window is high DPI, as on iOS: the layer takes the screen's scale. - Plume patch: on macOS the swap chain takes the window's Metal layer size (bounds x contents scale, truncated as MoltenVK does), and the swap chain keeps the VkResult of the last present and acquire. - game.log names the VkResult of every refused present and acquire (all platforms). A present still suboptimal after 8 rebuilds is kept (MoltenVK shows the image scaled) instead of closing the game. Co-Authored-By: Claude Opus 5.5 --- config/dependencies.lock.json | 4 +- docs/vulkan.md | 21 +++ patches/plume-present-result-layer-size.patch | 147 ++++++++++++++++++ src/native_graphics.cpp | 6 +- src/native_presentation.cpp | 64 +++++++- 5 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 patches/plume-present-result-layer-size.patch diff --git a/config/dependencies.lock.json b/config/dependencies.lock.json index 93991ff..59d479f 100644 --- a/config/dependencies.lock.json +++ b/config/dependencies.lock.json @@ -51,8 +51,8 @@ "commit": "11926860e878e68626ea99ec88562ce2b8badc4f", "path": "tools/Plume", "license": "MIT", - "patches": ["patches/plume-optional-extensions.patch", "patches/plume-pipeline-cache.patch", "patches/plume-swapchain-usage.patch", "patches/plume-swapchain-image-count.patch", "patches/plume-buffer-device-address-khr.patch", "patches/plume-dynamic-constant-buffers.patch", "patches/plume-descriptor-indexing-test-switch.patch", "patches/plume-optional-scalar-layout-device-address.patch", "patches/plume-d24s8-depth.patch", "patches/plume-bundled-vulkan-driver.patch"], - "patch_rationale": "Makes VK_EXT_robustness2 and VK_KHR_sampler_mirror_clamp_to_edge optional (Plume already has fallbacks for null descriptors), as some Android Vulkan drivers, including the emulator's, lack them; and frees the null-descriptor placeholder buffer before the allocator it came from. Adds an optional device-owned pipeline cache and a generation counter for application-managed persistence. Restricts swap-chain image usages to surface capabilities, stops creation after a failed capability query, and logs creation parameters on failure. Preserves the requested swap-chain image count across resizes instead of accumulating driver-provided extra images, and reapplies current surface count limits. Takes buffer device addresses through the KHR entry point where the core one is null (a Vulkan 1.1 device with VK_KHR_buffer_device_address, Issue #85). Adds dynamic uniform-buffer descriptor ranges so shader constants are bound per draw by offset instead of read through buffer addresses, which lets the Adreno compiler move them into its preamble. Adds a test switch that creates the device without the descriptor-indexing features, so the renderer's fallback for drivers without them (Issue #134) can run, and be checked by the validation layer, on a GPU that has them. Makes VK_EXT_scalar_block_layout and VK_KHR_buffer_device_address optional (no shader or buffer of the renderer needs them; a Vulkan 1.1 Mali-G76 lacks both, Issue #169), with test switches that leave them disabled and one that asks for a Vulkan 1.1 instance. Adds the D24_UNORM_S8_UINT depth format (appended to RenderFormat), for drivers without D32_SFLOAT_S8_UINT such as an old Mali's. Lets the app hand the Vulkan interface the vkGetInstanceProcAddr of a driver it loaded itself, so the macOS/iOS app uses its bundled MoltenVK even where a Vulkan SDK put a loader in /usr/local/lib (Issue #207).", + "patches": ["patches/plume-optional-extensions.patch", "patches/plume-pipeline-cache.patch", "patches/plume-swapchain-usage.patch", "patches/plume-swapchain-image-count.patch", "patches/plume-buffer-device-address-khr.patch", "patches/plume-dynamic-constant-buffers.patch", "patches/plume-descriptor-indexing-test-switch.patch", "patches/plume-optional-scalar-layout-device-address.patch", "patches/plume-d24s8-depth.patch", "patches/plume-bundled-vulkan-driver.patch", "patches/plume-present-result-layer-size.patch"], + "patch_rationale": "Makes VK_EXT_robustness2 and VK_KHR_sampler_mirror_clamp_to_edge optional (Plume already has fallbacks for null descriptors), as some Android Vulkan drivers, including the emulator's, lack them; and frees the null-descriptor placeholder buffer before the allocator it came from. Adds an optional device-owned pipeline cache and a generation counter for application-managed persistence. Restricts swap-chain image usages to surface capabilities, stops creation after a failed capability query, and logs creation parameters on failure. Preserves the requested swap-chain image count across resizes instead of accumulating driver-provided extra images, and reapplies current surface count limits. Takes buffer device addresses through the KHR entry point where the core one is null (a Vulkan 1.1 device with VK_KHR_buffer_device_address, Issue #85). Adds dynamic uniform-buffer descriptor ranges so shader constants are bound per draw by offset instead of read through buffer addresses, which lets the Adreno compiler move them into its preamble. Adds a test switch that creates the device without the descriptor-indexing features, so the renderer's fallback for drivers without them (Issue #134) can run, and be checked by the validation layer, on a GPU that has them. Makes VK_EXT_scalar_block_layout and VK_KHR_buffer_device_address optional (no shader or buffer of the renderer needs them; a Vulkan 1.1 Mali-G76 lacks both, Issue #169), with test switches that leave them disabled and one that asks for a Vulkan 1.1 instance. Adds the D24_UNORM_S8_UINT depth format (appended to RenderFormat), for drivers without D32_SFLOAT_S8_UINT such as an old Mali's. Lets the app hand the Vulkan interface the vkGetInstanceProcAddr of a driver it loaded itself, so the macOS/iOS app uses its bundled MoltenVK even where a Vulkan SDK put a loader in /usr/local/lib (Issue #207). Keeps the VkResult of the last present and acquire for the application to log, and on macOS sizes the swap chain from the window's Metal layer (bounds times contents scale, as MoltenVK does) instead of the window's backing scale, which made every present suboptimal on Retina and scaled displays (Issue #191).", "submodules": [ { "path": "contrib/D3D12MemoryAllocator", "commit": "9ef66bc14edd10dee0de3a545b98578363552f66" }, { "path": "contrib/Vulkan-Headers", "commit": "89268a6d17fc87003b209a1422c17ab288be99a0" }, diff --git a/docs/vulkan.md b/docs/vulkan.md index 2f96102..e25022c 100644 --- a/docs/vulkan.md +++ b/docs/vulkan.md @@ -170,6 +170,27 @@ M1(Metal System Trace,比賽中 8 秒):頂點 144.6 → 6.4 ms/幀,GPU `NATIVE_DISPATCH_LOOP removed=0|1`;`SFR_TEST_DISPATCH_LOOP=0` 在 Apple 上保留迴圈,`=1` 在任何 Vulkan 裝置上拿掉(驗證層用)。 +### macOS:Retina 與縮放顯示器上的 present + +v0.10.14 以前,macOS 的遊戲視窗不是高 DPI:SDL 的 Metal layer 的 contents scale 是 1,1280x720 +的視窗 layer 就是 1280x720 像素;Plume 的 `CocoaWindow` 卻用視窗的 backing scale(Retina 或縮放 +顯示器上是 2)算 swap chain,做成 2560x1440。MoltenVK 只認 layer 的自然大小(bounds 乘 +contents scale),大小不同的 swap chain 每次 present 都回 `VK_SUBOPTIMAL_KHR`,Plume 在 Apple 上把它 +當失敗,重建八次後遊戲停在 `failed to present native swap-chain texture`(Issue #191,M1 MacBook +Pro 的內建螢幕和縮放成 2560x1440 的外接螢幕都會,1x 的 1920x1080 螢幕不會)。 + +- macOS 的遊戲視窗改成高 DPI(`native_graphics.cpp`,和 iOS 一樣),layer 的 scale 就是螢幕的。 +- `patches/plume-present-result-layer-size.patch`:macOS 的 swap chain 大小改取視窗裡 Metal layer + 的 bounds 乘 contents scale(截斷,和 MoltenVK 一樣),兩者不會再不同;swap chain 也記下最後一次 + present 與 acquire 的 `VkResult`。 +- `game.log` 的 `NATIVE_PRESENTATION present_refused` 與 `acquire_refused` 都記 + `result=<名稱>(<數字>)`(所有平台);重建八次仍是 `VK_SUBOPTIMAL_KHR` 時記一次 + `NATIVE_PRESENTATION suboptimal_kept`,繼續執行(MoltenVK 仍會把這張圖縮放顯示),不再結束遊戲。 + +在只有 1x 螢幕的 Mac 上,可以用私有的 `CGVirtualDisplay` 建一個虛擬螢幕,設成 1280x720 HiDPI +(2560x1440 像素)模式來重現。注意:macOS 27 上建立它的程序結束後,虛擬螢幕不會消失(WindowServer +記錄 `CGXVirtualDisplayDestroy ... not found`),要登出或重開機才會移除。 + ### 圖形自我測試的繪製 `graphics_selftest.cpp`(Android 由 `GraphicsTestActivity` 啟動,桌面設 `SFR_GRAPHICS_SELFTEST=1` diff --git a/patches/plume-present-result-layer-size.patch b/patches/plume-present-result-layer-size.patch new file mode 100644 index 0000000..20997c4 --- /dev/null +++ b/patches/plume-present-result-layer-size.patch @@ -0,0 +1,147 @@ +diff --git a/plume_apple.mm b/plume_apple.mm +index 64e4dc9..fd78064 100644 +--- a/plume_apple.mm ++++ b/plume_apple.mm +@@ -10,6 +10,7 @@ + #import + #import + #import ++#import + + static uint32_t plumeGetEntryProperty(io_registry_entry_t entry, CFStringRef propertyName) { + uint32_t value = 0; +@@ -48,19 +49,57 @@ namespace plume { + + // MARK: - CocoaWindow + ++ // The CAMetalLayer drawn into (a Metal view's, such as SDL's) in the window's views. ++ static CAMetalLayer *findMetalLayer(NSView *view, int depth) { ++ if ([view.layer isKindOfClass:CAMetalLayer.class]) { ++ return (CAMetalLayer *)view.layer; ++ } ++ ++ if (depth <= 0) { ++ return nil; ++ } ++ ++ for (NSView *child in view.subviews) { ++ if (CAMetalLayer *layer = findMetalLayer(child, depth - 1)) { ++ return layer; ++ } ++ } ++ ++ return nil; ++ } ++ ++ // The window's size in pixels. With a Metal layer in it, the layer's own size in pixels ++ // (bounds times contents scale, truncated): the size MoltenVK holds the swap chain to. ++ // The window's backing scale differs from the layer's when the view is not high ++ // resolution (a Retina or scaled display), and a swap chain of the window's size then ++ // made every present suboptimal. ++ static CocoaWindowAttributes readWindowAttributes(NSWindow *nsWindow) { ++ NSRect contentFrame = [[nsWindow contentView] frame]; ++ CocoaWindowAttributes attributes; ++ attributes.x = (int)round(contentFrame.origin.x); ++ attributes.y = (int)round(contentFrame.origin.y); ++ ++ CAMetalLayer *layer = findMetalLayer([nsWindow contentView], 4); ++ if ((layer != nil) && (layer.bounds.size.width >= 1.0) && (layer.bounds.size.height >= 1.0)) { ++ const CGFloat layerScale = layer.contentsScale; ++ attributes.width = (int)trunc(layer.bounds.size.width * layerScale); ++ attributes.height = (int)trunc(layer.bounds.size.height * layerScale); ++ return attributes; ++ } ++ ++ CGFloat scaleFactor = [nsWindow backingScaleFactor]; ++ attributes.width = (int)round(contentFrame.size.width * scaleFactor); ++ attributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ return attributes; ++ } ++ + CocoaWindow::CocoaWindow(void* window) + : windowHandle(window), cachedRefreshRate(0) { + cachedAttributes = {0, 0, 0, 0}; + + if ([NSThread isMainThread]) { + NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; +- +- cachedAttributes.x = (int)round(contentFrame.origin.x); +- cachedAttributes.y = (int)round(contentFrame.origin.y); +- cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ cachedAttributes = readWindowAttributes(nsWindow); + + NSScreen *screen = [nsWindow screen]; + if (@available(macOS 12.0, *)) { +@@ -77,14 +116,10 @@ namespace plume { + void CocoaWindow::updateWindowAttributesInternal(bool forceSync) { + auto updateBlock = ^{ + NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; ++ const CocoaWindowAttributes attributes = readWindowAttributes(nsWindow); + + std::lock_guard lock(attributesMutex); +- cachedAttributes.x = (int)round(contentFrame.origin.x); +- cachedAttributes.y = (int)round(contentFrame.origin.y); +- cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ cachedAttributes = attributes; + }; + + if (forceSync) { +@@ -113,15 +148,11 @@ namespace plume { + void CocoaWindow::getWindowAttributes(CocoaWindowAttributes* attributes) const { + if ([NSThread isMainThread]) { + NSWindow *nsWindow = (__bridge NSWindow *)windowHandle; +- NSRect contentFrame = [[nsWindow contentView] frame]; +- CGFloat scaleFactor = [nsWindow backingScaleFactor]; ++ const CocoaWindowAttributes current = readWindowAttributes(nsWindow); + + { + std::lock_guard lock(attributesMutex); +- const_cast(this)->cachedAttributes.x = (int)round(contentFrame.origin.x); +- const_cast(this)->cachedAttributes.y = (int)round(contentFrame.origin.y); +- const_cast(this)->cachedAttributes.width = (int)round(contentFrame.size.width * scaleFactor); +- const_cast(this)->cachedAttributes.height = (int)round(contentFrame.size.height * scaleFactor); ++ const_cast(this)->cachedAttributes = current; + + *attributes = cachedAttributes; + } +diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp +index a2e3b1b..cb03fea 100644 +--- a/plume_vulkan.cpp ++++ b/plume_vulkan.cpp +@@ -2319,6 +2319,7 @@ namespace plume { + const std::scoped_lock queueLock(*commandQueue->queue->mutex); + res = vkQueuePresentKHR(commandQueue->queue->vk, &presentInfo); + } ++ lastPresentResult = res; + + #if defined(__APPLE__) + // Under MoltenVK, VK_SUBOPTIMAL_KHR does not result in a valid state for rendering. We intentionally +@@ -2523,6 +2524,7 @@ namespace plume { + + VulkanCommandSemaphore *interfaceSemaphore = static_cast(signalSemaphore); + VkResult res = vkAcquireNextImageKHR(commandQueue->device->vk, vk, UINT64_MAX, interfaceSemaphore->vk, VK_NULL_HANDLE, textureIndex); ++ lastAcquireResult = res; + if ((res != VK_SUCCESS) && (res != VK_SUBOPTIMAL_KHR)) { + return false; + } +diff --git a/plume_vulkan.h b/plume_vulkan.h +index 1ff774c..da1968c 100644 +--- a/plume_vulkan.h ++++ b/plume_vulkan.h +@@ -245,6 +245,10 @@ namespace plume { + uint64_t currentPresentId = 0; + bool immediatePresentModeSupported = false; + uint32_t maxFrameLatency = 0; ++ // What the last vkQueuePresentKHR and vkAcquireNextImageKHR returned, ++ // for the application to report or act on when they are refused. ++ VkResult lastPresentResult = VK_SUCCESS; ++ VkResult lastAcquireResult = VK_SUCCESS; + + VulkanSwapChain(VulkanCommandQueue *commandQueue, RenderWindow renderWindow, uint32_t textureCount, RenderFormat format, uint32_t maxFrameLatency); + ~VulkanSwapChain() override; diff --git a/src/native_graphics.cpp b/src/native_graphics.cpp index 6f86196..c0d2638 100644 --- a/src/native_graphics.cpp +++ b/src/native_graphics.cpp @@ -282,7 +282,11 @@ void NativeGraphics::initialize() { // MoltenVK refused every present as suboptimal (Issue #185). SDL_WINDOW_METAL | SDL_WINDOW_HIDDEN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); #elif defined(__APPLE__) - SDL_WINDOW_METAL | SDL_WINDOW_HIDDEN | SDL_WINDOW_RESIZABLE); + // High DPI here too: on a Retina or scaled display the + // layer was 1280x720 at scale 1 while the swap chain took + // the window's backing scale (2560x1440), and MoltenVK + // refused every present as suboptimal (Issue #191). + SDL_WINDOW_METAL | SDL_WINDOW_HIDDEN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); #else SDL_WINDOW_VULKAN | SDL_WINDOW_HIDDEN | SDL_WINDOW_RESIZABLE); #endif diff --git a/src/native_presentation.cpp b/src/native_presentation.cpp index 2de7c0f..f34afab 100644 --- a/src/native_presentation.cpp +++ b/src/native_presentation.cpp @@ -212,6 +212,31 @@ void set_fullscreen(SDL_Window* window, bool fullscreen) { } #endif +// A VkResult as game.log shows it: its name when it is one a swap chain +// returns, and its number. +std::string vk_result_text(VkResult result) { + const char* name = "VkResult"; + switch (result) { + case VK_SUCCESS: name = "VK_SUCCESS"; break; + case VK_NOT_READY: name = "VK_NOT_READY"; break; + case VK_TIMEOUT: name = "VK_TIMEOUT"; break; + case VK_SUBOPTIMAL_KHR: name = "VK_SUBOPTIMAL_KHR"; break; + case VK_ERROR_OUT_OF_DATE_KHR: name = "VK_ERROR_OUT_OF_DATE_KHR"; break; + case VK_ERROR_SURFACE_LOST_KHR: name = "VK_ERROR_SURFACE_LOST_KHR"; break; + case VK_ERROR_DEVICE_LOST: name = "VK_ERROR_DEVICE_LOST"; break; + case VK_ERROR_OUT_OF_HOST_MEMORY: name = "VK_ERROR_OUT_OF_HOST_MEMORY"; break; + case VK_ERROR_OUT_OF_DEVICE_MEMORY: name = "VK_ERROR_OUT_OF_DEVICE_MEMORY"; break; + case VK_ERROR_FULL_SCREEN_EXCLUSIVE_MODE_LOST_EXT: name = "VK_ERROR_FULL_SCREEN_EXCLUSIVE_MODE_LOST_EXT"; break; + case VK_ERROR_UNKNOWN: name = "VK_ERROR_UNKNOWN"; break; + default: break; + } + return std::string(name) + '(' + std::to_string(int(result)) + ')'; +} + +std::string with_result(const char* message, const std::string& result) { + return result.empty() ? std::string(message) : std::string(message) + ": " + result; +} + [[noreturn]] void unavailable(const char* object) { throw std::runtime_error(std::string("failed to create native presentation ") + object); } @@ -429,7 +454,14 @@ struct NativePresentation::Impl { // VK_SUBOPTIMAL_KHR: the layer is no longer the swap chain's size): the // swap chain is made again next frame, a few times in a row before giving up. uint32_t present_failures = 0; + // MoltenVK still shows an image it calls suboptimal (scaled into the + // layer). When a swap chain made again does not help, the game goes on + // with it instead of closing (Issue #191); a normal present ends this. + bool suboptimal_kept = false; bool present_refused(); + // What the swap chain's last present or acquire returned, for game.log + // (Vulkan; empty on D3D12). + std::string swap_chain_result(bool present) const; #if defined(__ANDROID__) || defined(SFR_IOS) // Android destroys the window's surface in the background; the swap chain // is made again on the new one when the app returns (or a present fails). @@ -1776,9 +1808,26 @@ std::vector NativePresentation::readback_color() { #endif } +std::string NativePresentation::Impl::swap_chain_result(bool present) const { + if (graphics->backend() != GraphicsBackend::vulkan || !swap_chain) return {}; + const auto* vulkan = static_cast(swap_chain.get()); + return vk_result_text(present ? vulkan->lastPresentResult : vulkan->lastAcquireResult); +} + bool NativePresentation::Impl::present_refused() { - if (graphics->backend() != GraphicsBackend::vulkan || ++present_failures > 8) return false; + if (graphics->backend() != GraphicsBackend::vulkan) return false; + const bool suboptimal = + static_cast(swap_chain.get())->lastPresentResult == VK_SUBOPTIMAL_KHR; + if (suboptimal && suboptimal_kept) return true; + if (++present_failures > 8) { + if (!suboptimal) return false; + suboptimal_kept = true; + std::cerr << "NATIVE_PRESENTATION suboptimal_kept size=" << swap_chain->getWidth() << 'x' + << swap_chain->getHeight() << '\n'; + return true; + } std::cerr << "NATIVE_PRESENTATION present_refused attempt=" << present_failures + << " result=" << swap_chain_result(true) << " size=" << swap_chain->getWidth() << 'x' << swap_chain->getHeight() << '\n'; #ifdef SFR_IOS if (present_failures > 4) { @@ -1948,11 +1997,14 @@ void NativePresentation::present(uint32_t area_width, uint32_t area_height, floa } if (!impl_->swap_chain->acquireTexture(acquired, &texture_index)) { #ifdef __ANDROID__ + std::cerr << "NATIVE_PRESENTATION acquire_refused result=" << impl_->swap_chain_result(false) + << " rebuild=1\n"; impl_->rebuild_swap_chain = true; // the surface went away return; #endif if (++impl_->acquire_failures <= 8) { - std::cerr << "NATIVE_PRESENTATION acquire_refused attempt=" << impl_->acquire_failures << '\n'; + std::cerr << "NATIVE_PRESENTATION acquire_refused attempt=" << impl_->acquire_failures + << " result=" << impl_->swap_chain_result(false) << '\n'; #ifdef SFR_IOS if (impl_->acquire_failures > 4) { impl_->rebuild_swap_chain = true; // a new size did not help: a new surface @@ -1962,7 +2014,7 @@ void NativePresentation::present(uint32_t area_width, uint32_t area_height, floa impl_->resize_swap_chain = true; return; } - throw std::runtime_error("failed to acquire native presentation texture"); + throw std::runtime_error(with_result("failed to acquire native presentation texture", impl_->swap_chain_result(false))); } impl_->acquire_failures = 0; if (texture_index >= impl_->swap_chain->getTextureCount() || @@ -2097,14 +2149,18 @@ void NativePresentation::present(uint32_t area_width, uint32_t area_height, floa } if (!impl_->swap_chain->present(texture_index, rendered ? &rendered : nullptr, rendered ? 1 : 0)) { #ifdef __ANDROID__ + std::cerr << "NATIVE_PRESENTATION present_refused result=" << impl_->swap_chain_result(true) + << " rebuild=1\n"; impl_->rebuild_swap_chain = true; return; #endif // Made again next frame (the window is still shown below: a hidden // one may be what refuses). - if (!impl_->present_refused()) throw std::runtime_error("failed to present native swap-chain texture"); + if (!impl_->present_refused()) + throw std::runtime_error(with_result("failed to present native swap-chain texture", impl_->swap_chain_result(true))); } else { impl_->present_failures = 0; + impl_->suboptimal_kept = false; } if (!impl_->shown) { #ifdef _WIN32