From 47b96f711df740c37ef3ebbd50a465cecf6026e7 Mon Sep 17 00:00:00 2001 From: Dragos Hont <2241895+dragoshont@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:11:47 +0300 Subject: [PATCH] display(macOS): pace the VM display with CAMetalDisplayLink On a MacBook Pro with an external 60 Hz main display, a VM window on the built-in 120 Hz ProMotion panel never showed more than 60 frames per second. MTKView's own timer stayed at 60 callbacks per second there even with preferredFramesPerSecond = 120. A CAMetalDisplayLink on the same layer runs at the rate it requests, and its frames reach the screen at 120 Hz. On macOS 14 and later, drive the renderer from a CAMetalDisplayLink and hand it the drawable from each update. The rate follows the screen the window is on, and an FPS limit changed in Settings now applies the next time the VM starts, not only when its window is created. Earlier macOS versions keep the view's own timer. Assisted-by: GitHub Copilot:claude-opus-5.5 --- .../VMDisplayQemuMetalWindowController.swift | 65 +++++++++++++++++-- Platform/macOS/Display/VMMetalView.swift | 20 ++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift b/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift index 7f8961c511..e2dbc2c54e 100644 --- a/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift +++ b/Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift @@ -45,6 +45,8 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { private var globalEventMonitor: Any? = nil private var ctrlKeyDown: Bool = false private var screenChangedToken: Any? + /// `CAMetalDisplayLink` on macOS 14 and later + private var displayLink: AnyObject? private var displayConfig: UTMQemuConfigurationDisplay? { vmQemuConfig?.displays[id] @@ -97,17 +99,27 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { logger.critical("Failed to create renderer.") return } - if rendererFpsLimit > 0 { - metalView.preferredFramesPerSecond = rendererFpsLimit - } else if let maxFps = self.window?.screen?.maximumFramesPerSecond { - metalView.preferredFramesPerSecond = maxFps - } + if #available(macOS 14, *), let layer = metalView.layer as? CAMetalLayer { + // A CAMetalDisplayLink paces the layer at the rate it requests; MTKView's own + // timer can stay at 60 Hz on a ProMotion display. + let link = CAMetalDisplayLink(metalLayer: layer) + link.delegate = self + link.add(to: .main, forMode: .common) + metalView.isPaused = true + metalView.enableSetNeedsDisplay = false + displayLink = link + } + updateFrameRate() renderer.changeUpscaler(displayConfig?.upscalingFilter.metalSamplerMinMagFilter ?? .linear, downscaler: displayConfig?.downscalingFilter.metalSamplerMinMagFilter ?? .linear) vmDisplay?.addRenderer(renderer) // can be nil if primary metalView.delegate = renderer metalView.inputDelegate = self - screenChangedToken = NotificationCenter.default.addObserver(forName: NSWindow.didChangeScreenNotification, object: nil, queue: .main) { [weak self] _ in + screenChangedToken = NotificationCenter.default.addObserver(forName: NSWindow.didChangeScreenNotification, object: nil, queue: .main) { [weak self] notification in + // the new screen can refresh at a different rate + if let self = self, notification.object as? NSWindow == window { + updateFrameRate() + } // update minSize when we change screens if let self = self, let window = window, @@ -124,7 +136,29 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { super.windowDidLoad() } + /// Refresh at the rate of the screen the window is on, unless the user set a limit. + private func updateFrameRate() { + let fps: Int + if rendererFpsLimit > 0 { + fps = rendererFpsLimit + } else if let maxFps = window?.screen?.maximumFramesPerSecond { + fps = maxFps + } else { + return + } + if #available(macOS 14, *), let link = displayLink as? CAMetalDisplayLink { + let rate = Float(fps) + link.preferredFrameRateRange = CAFrameRateRange(minimum: rate, maximum: rate, preferred: rate) + } else { + metalView.preferredFramesPerSecond = fps + } + } + override func windowWillClose(_ notification: Notification) { + if #available(macOS 14, *), let link = displayLink as? CAMetalDisplayLink { + link.invalidate() + } + displayLink = nil vmDisplay?.removeRenderer(renderer!) stopAllCapture() if let screenChangedToken = screenChangedToken { @@ -160,6 +194,11 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { displaySizeDidChange(size: vmDisplay.displaySize) } super.enterLive() + // pick up an FPS limit changed while the window was open + updateFrameRate() + if #available(macOS 14, *), let link = displayLink as? CAMetalDisplayLink { + link.isPaused = false + } setControl(.resize, isEnabled: false) // disable item if isWindowFocusAutoCapture { captureMouse() @@ -167,6 +206,9 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController { } override func enterSuspended(isBusy busy: Bool) { + if #available(macOS 14, *), let link = displayLink as? CAMetalDisplayLink { + link.isPaused = true + } if !busy { metalView.isHidden = true screenshotView.image = vm.screenshot?.image @@ -735,3 +777,14 @@ extension VMDisplayQemuMetalWindowController { window.beginSheet(sheetWindow) } } + +// MARK: - Display link + +@available(macOS 14, *) +extension VMDisplayQemuMetalWindowController: CAMetalDisplayLinkDelegate { + func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { + metalView.displayLinkDrawable = update.drawable + renderer.draw(in: metalView) + metalView.displayLinkDrawable = nil + } +} diff --git a/Platform/macOS/Display/VMMetalView.swift b/Platform/macOS/Display/VMMetalView.swift index dbaff57f3e..6a1ff613ed 100644 --- a/Platform/macOS/Display/VMMetalView.swift +++ b/Platform/macOS/Display/VMMetalView.swift @@ -30,6 +30,26 @@ class VMMetalView: MTKView { @Setting("IsISOKeySwapped") private var isISOKeySwapped = false @Setting("IsCapsLockKey") private var isCapsLockKey: Bool = false + /// Drawable supplied by a display link for the frame being drawn, used instead of one + /// acquired from the layer. + var displayLinkDrawable: CAMetalDrawable? + + override var currentDrawable: CAMetalDrawable? { + displayLinkDrawable ?? super.currentDrawable + } + + override var currentRenderPassDescriptor: MTLRenderPassDescriptor? { + guard let drawable = displayLinkDrawable else { + return super.currentRenderPassDescriptor + } + let descriptor = MTLRenderPassDescriptor() + descriptor.colorAttachments[0].texture = drawable.texture + descriptor.colorAttachments[0].loadAction = .clear + descriptor.colorAttachments[0].storeAction = .store + descriptor.colorAttachments[0].clearColor = clearColor + return descriptor + } + private enum ModifierKeySwap { case none case ctrlCmd