internal/graphicsdriver/metal: use CAMetalDisplayLink for macOS

This should fix the flickering issue.

Closes #3278
This commit is contained in:
Hajime Hoshi
2025-08-27 23:44:31 +09:00
parent 95f12febfd
commit 329fd15b10
7 changed files with 438 additions and 58 deletions

View File

@@ -21,15 +21,21 @@ import (
)
var (
class_NSInvocation = objc.GetClass("NSInvocation")
class_NSMethodSignature = objc.GetClass("NSMethodSignature")
class_NSAutoreleasePool = objc.GetClass("NSAutoreleasePool")
class_NSString = objc.GetClass("NSString")
class_NSColor = objc.GetClass("NSColor")
class_NSScreen = objc.GetClass("NSScreen")
class_NSInvocation = objc.GetClass("NSInvocation")
class_NSMethodSignature = objc.GetClass("NSMethodSignature")
class_NSAutoreleasePool = objc.GetClass("NSAutoreleasePool")
class_NSString = objc.GetClass("NSString")
class_NSColor = objc.GetClass("NSColor")
class_NSScreen = objc.GetClass("NSScreen")
class_NSRunLoop = objc.GetClass("NSRunLoop")
class_NSMachPort = objc.GetClass("NSMachPort")
class_NSWorkspace = objc.GetClass("NSWorkspace")
class_NSNotificationCenter = objc.GetClass("NSNotificationCenter")
class_NSOperationQueue = objc.GetClass("NSOperationQueue")
)
var (
sel_retain = objc.RegisterName("retain")
sel_alloc = objc.RegisterName("alloc")
sel_new = objc.RegisterName("new")
sel_release = objc.RegisterName("release")
@@ -60,6 +66,17 @@ var (
sel_unsignedIntValue = objc.RegisterName("unsignedIntValue")
sel_setLayer = objc.RegisterName("setLayer:")
sel_setWantsLayer = objc.RegisterName("setWantsLayer:")
sel_mainRunLoop = objc.RegisterName("mainRunLoop")
sel_currentRunLoop = objc.RegisterName("currentRunLoop")
sel_run = objc.RegisterName("run")
sel_performBlock = objc.RegisterName("performBlock:")
sel_port = objc.RegisterName("port")
sel_addPort = objc.RegisterName("addPort:forMode:")
sel_sharedWorkspace = objc.RegisterName("sharedWorkspace")
sel_notificationCenter = objc.RegisterName("notificationCenter")
sel_addObserver = objc.RegisterName("addObserver:selector:name:object:")
sel_addObserverForName = objc.RegisterName("addObserverForName:object:queue:usingBlock:")
sel_mainQueue = objc.RegisterName("mainQueue")
)
const (
@@ -95,6 +112,14 @@ type NSPoint = CGPoint
type NSRect = CGRect
type NSSize = CGSize
type NSObject struct {
objc.ID
}
func (n NSObject) Retain() {
n.Send(sel_retain)
}
type NSError struct {
objc.ID
}
@@ -268,3 +293,75 @@ type NSNumber struct {
func (n NSNumber) UnsignedIntValue() uint {
return uint(n.Send(sel_unsignedIntValue))
}
type NSRunLoop struct {
objc.ID
}
func NSRunLoop_mainRunLoop() NSRunLoop {
return NSRunLoop{objc.ID(class_NSRunLoop).Send(sel_mainRunLoop)}
}
func NSRunLoop_currentRunLoop() NSRunLoop {
return NSRunLoop{objc.ID(class_NSRunLoop).Send(sel_currentRunLoop)}
}
func (r NSRunLoop) AddPort(port NSMachPort, mode NSRunLoopMode) {
r.Send(sel_addPort, port.ID, mode)
}
func (r NSRunLoop) Run() {
r.Send(sel_run)
}
func (r NSRunLoop) PerformBlock(block objc.Block) {
r.Send(sel_performBlock, block)
}
type NSRunLoopMode NSString
var (
NSRunLoopCommonModes = NSRunLoopMode(NSString_alloc().InitWithUTF8String("kCFRunLoopCommonModes"))
NSDefaultRunLoopMode = NSRunLoopMode(NSString_alloc().InitWithUTF8String("kCFRunLoopDefaultMode"))
)
type NSMachPort struct {
objc.ID
}
func NSMachPort_port() NSMachPort {
return NSMachPort{objc.ID(class_NSMachPort).Send(sel_port)}
}
type NSWorkspace struct {
objc.ID
}
func NSWorkspace_sharedWorkspace() NSWorkspace {
return NSWorkspace{objc.ID(class_NSWorkspace).Send(sel_sharedWorkspace)}
}
func (w NSWorkspace) NotificationCenter() NSNotificationCenter {
return NSNotificationCenter{w.Send(sel_notificationCenter)}
}
var (
NSWorkspaceDidWakeNotification = NSString_alloc().InitWithUTF8String("NSWorkspaceDidWakeNotification")
NSWorkspaceScreensDidWakeNotification = NSString_alloc().InitWithUTF8String("NSWorkspaceScreensDidWakeNotification")
)
type NSNotificationCenter struct {
objc.ID
}
func (n NSNotificationCenter) AddObserverForName(name NSString, object objc.ID, queue NSOperationQueue, usingBlock objc.Block) objc.ID {
return n.Send(sel_addObserverForName, name.ID, object, queue.ID, usingBlock)
}
type NSOperationQueue struct {
objc.ID
}
func NSOperationQueue_mainQueue() NSOperationQueue {
return NSOperationQueue{objc.ID(class_NSOperationQueue).Send(sel_mainQueue)}
}

View File

@@ -33,7 +33,11 @@ import (
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/mtl"
)
var class_CAMetalLayer = objc.GetClass("CAMetalLayer")
var (
class_CAMetalLayer = objc.GetClass("CAMetalLayer")
class_CAMetalDisplayLink = objc.GetClass("CAMetalDisplayLink")
class_CAMetalDisplayLinkUpdate = objc.GetClass("CAMetalDisplayLinkUpdate")
)
var (
sel_pixelFormat = objc.RegisterName("pixelFormat")
@@ -51,6 +55,14 @@ var (
sel_setFramebufferOnly = objc.RegisterName("setFramebufferOnly:")
sel_texture = objc.RegisterName("texture")
sel_present = objc.RegisterName("present")
sel_alloc = objc.RegisterName("alloc")
sel_initWithMetalLayer = objc.RegisterName("initWithMetalLayer:")
sel_setDelegate = objc.RegisterName("setDelegate:")
sel_addToOneLoopForMode = objc.RegisterName("addToRunLoop:forMode:")
sel_removeFromRunLoopForMode = objc.RegisterName("removeFromRunLoop:forMode:")
sel_setPaused = objc.RegisterName("setPaused:")
sel_drawable = objc.RegisterName("drawable")
sel_release = objc.RegisterName("release")
)
// Layer is an object that manages image-based content and
@@ -217,7 +229,7 @@ func (ml MetalLayer) SetPresentsWithTransaction(presentsWithTransaction bool) {
// SetFramebufferOnly sets a Boolean value that determines whether the layers textures are used only for rendering.
//
// https://developer.apple.com/documentation/quartzcore/cametallayer/1478168-framebufferonly?language=objc
// Reference: https://developer.apple.com/documentation/quartzcore/cametallayer/1478168-framebufferonly?language=objc
func (ml MetalLayer) SetFramebufferOnly(framebufferOnly bool) {
ml.metalLayer.Send(sel_setFramebufferOnly, framebufferOnly)
}
@@ -247,3 +259,64 @@ func (md MetalDrawable) Texture() mtl.Texture {
func (md MetalDrawable) Present() {
md.metalDrawable.Send(sel_present)
}
// MetalDisplayLink is a class your Metal app uses to register for callbacks to synchronize its animations for a display.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink?language=objc
type MetalDisplayLink struct {
objc.ID
}
// SetDelegate sets an instance of a type your app implements that responds to the systems callbacks.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/delegate?language=objc
func (m MetalDisplayLink) SetDelegate(delegate objc.ID) {
m.Send(sel_setDelegate, delegate)
}
// AddToRunLoop registers the display link with a run loop.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/add(to:formode:)?language=objc
func (m MetalDisplayLink) AddToRunLoop(runLoop cocoa.NSRunLoop, mode cocoa.NSRunLoopMode) {
m.Send(sel_addToOneLoopForMode, runLoop, mode)
}
// RemoveFromRunLoop removes a modes display link from a run loop.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/remove(from:formode:)?language=objc
func (m MetalDisplayLink) RemoveFromRunLoop(runLoop cocoa.NSRunLoop, mode cocoa.NSRunLoopMode) {
m.Send(sel_removeFromRunLoopForMode, runLoop, mode)
}
// SetPaused sets a Boolean value that indicates whether the system suspends the display links notifications to the target.
//
// https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/ispaused?language=objc
func (m MetalDisplayLink) SetPaused(paused bool) {
m.Send(sel_setPaused, paused)
}
func (m MetalDisplayLink) Release() {
m.Send(sel_release)
}
// NewMetalDisplayLink creates a display link for Metal from a Core Animation layer.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/init(metallayer:)?language=objc
func NewMetalDisplayLink(metalLayer MetalLayer) MetalDisplayLink {
displayLink := objc.ID(class_CAMetalDisplayLink).Send(sel_alloc).Send(sel_initWithMetalLayer, metalLayer.metalLayer)
return MetalDisplayLink{displayLink}
}
// MetalDisplayLinkUpdate stores information about a single update from a Metal display link instance.
//
// Reference: https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/update?language=objc
type MetalDisplayLinkUpdate struct {
objc.ID
}
// Drawable returns the Metal drawable your app uses to render the next frame.
//
// https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/update/drawable?language=objc
func (m MetalDisplayLinkUpdate) Drawable() MetalDrawable {
return MetalDrawable{m.Send(sel_drawable)}
}

View File

@@ -0,0 +1,219 @@
// Copyright 2025 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build darwin && !ios
package metal
// #cgo CFLAGS: -x objective-c
//
// #include <Foundation/Foundation.h>
// #include <CoreVideo/CVDisplayLink.h>
// #if __has_include(<QuartzCore/CAMetalLayer.h>)
// #include <QuartzCore/CAMetalLayer.h>
// #endif
//
// static bool isCAMetalDisplayLinkAvailable() {
// // TODO: Use PureGo if returning a struct is supported (ebitengine/purego#225).
// // As operatingSystemVersion returns a struct, this cannot be written with PureGo.
// NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion];
// if (version.majorVersion >= 14) {
// // Also check if the CAMetalDisplayLink class exists
// return NSClassFromString(@"CAMetalDisplayLink") != nil;
// }
// return false;
// }
//
// int ebitengine_DisplayLinkOutputCallback(CVDisplayLinkRef displayLinkRef, CVTimeStamp* inNow, CVTimeStamp* inOutputTime, uint64_t flagsIn, uint64_t* flagsOut, void* displayLinkContext);
import "C"
import (
"runtime"
"runtime/cgo"
"time"
"unsafe"
"github.com/ebitengine/purego/objc"
"github.com/hajimehoshi/ebiten/v2/internal/cocoa"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/ca"
)
func (v *view) initDisplayLink() error {
if C.isCAMetalDisplayLinkAvailable() {
if err := v.initCAMetalDisplayLink(); err != nil {
return err
}
return nil
}
if err := v.initCADisplayLink(); err != nil {
return err
}
return nil
}
var class_EbitengineCAMetalDisplayLinkDelegate objc.Class
func (v *view) initCAMetalDisplayLink() error {
v.drawableCh = make(chan ca.MetalDrawable)
v.drawableDoneCh = make(chan struct{})
v.metalDisplayLinkRunLoop = createThreadWithRunLoop()
v.prevMetalDisplayLink = make(chan uintptr, 1)
c, err := objc.RegisterClass(
"EbitengineCAMetalDisplayLinkDelegate",
objc.GetClass("NSObject"),
[]*objc.Protocol{objc.GetProtocol("CAMetalDisplayLinkDelegate")},
nil,
[]objc.MethodDef{
{
Cmd: objc.RegisterName("metalDisplayLink:needsUpdate:"),
Fn: func(id objc.ID, cmd objc.SEL, metalDisplayLink objc.ID, needsUpdate objc.ID) {
drawable := ca.MetalDisplayLinkUpdate{ID: needsUpdate}.Drawable()
if drawable == (ca.MetalDrawable{}) {
return
}
v.drawableCh <- drawable
<-v.drawableDoneCh
},
},
},
)
if err != nil {
return err
}
class_EbitengineCAMetalDisplayLinkDelegate = c
v.createCAMetalDisplayLink()
// Recreate the display link when the app is recovered from sleep.
// TODO: Recreation might be needed when the display is changed.
nc := cocoa.NSWorkspace_sharedWorkspace().NotificationCenter()
if v.meltaDisplayLinkRecreateBlock == 0 {
v.meltaDisplayLinkRecreateBlock = objc.NewBlock(func(block objc.Block) {
v.createCAMetalDisplayLink()
})
}
mainQueue := cocoa.NSOperationQueue_mainQueue()
v.notificatioObserver = nc.AddObserverForName(cocoa.NSWorkspaceDidWakeNotification, 0, mainQueue, v.meltaDisplayLinkRecreateBlock)
cocoa.NSObject{ID: v.notificatioObserver}.Retain()
return nil
}
func (v *view) createCAMetalDisplayLink() {
// Release the previous display link if any.
// This is done in the thread for the display link, so that the callback is not called during releasing.
if v.metalDisplayLink != 0 {
// Unfortunately, there is no blocking 'performBlock' for NSRunLoop, so use a channel to wait.
if v.metalDisplayLinkReleaseBlock == 0 {
v.metalDisplayLinkReleaseBlock = objc.NewBlock(func(block objc.Block) {
dl := ca.MetalDisplayLink{ID: objc.ID(<-v.prevMetalDisplayLink)}
dl.RemoveFromRunLoop(v.metalDisplayLinkRunLoop, cocoa.NSDefaultRunLoopMode)
dl.Release()
})
}
v.prevMetalDisplayLink <- v.metalDisplayLink
v.metalDisplayLinkRunLoop.PerformBlock(v.metalDisplayLinkReleaseBlock)
}
dl := ca.NewMetalDisplayLink(v.ml)
dl.SetDelegate(objc.ID(class_EbitengineCAMetalDisplayLinkDelegate).Send(objc.RegisterName("new")))
dl.AddToRunLoop(v.metalDisplayLinkRunLoop, cocoa.NSDefaultRunLoopMode)
dl.SetPaused(false)
v.metalDisplayLink = uintptr(dl.ID)
}
func createThreadWithRunLoop() cocoa.NSRunLoop {
ch := make(chan cocoa.NSRunLoop)
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
runLoop := cocoa.NSRunLoop_currentRunLoop()
ch <- runLoop
close(ch)
// Add a dummy mach port to keep alive.
port := cocoa.NSMachPort_port()
runLoop.AddPort(port, cocoa.NSRunLoopCommonModes)
runLoop.Run()
}()
runLoop := <-ch
if runLoop.ID == 0 {
panic("metal: runLoop must be initialized")
}
return runLoop
}
func (v *view) initCADisplayLink() error {
v.fence = newFence()
// TODO: CVDisplayLink APIs are deprecated in macOS 10.15 and later.
// Use new APIs like NSView.displayLink(target:selector:).
var displayLinkRef C.CVDisplayLinkRef
if ret := C.CVDisplayLinkCreateWithActiveCGDisplays(&displayLinkRef); ret != kCVReturnSuccess {
// Failed to get the display link, so proceed without it.
return nil
}
v.handleToSelf = cgo.NewHandle(v)
C.CVDisplayLinkSetOutputCallback(displayLinkRef, C.CVDisplayLinkOutputCallback(C.ebitengine_DisplayLinkOutputCallback), unsafe.Pointer(&v.handleToSelf))
C.CVDisplayLinkStart(displayLinkRef)
v.caDisplayLink = uintptr(displayLinkRef)
return nil
}
//export ebitengine_DisplayLinkOutputCallback
func ebitengine_DisplayLinkOutputCallback(displayLinkRef C.CVDisplayLinkRef, inNow, inOutputTime *C.CVTimeStamp, flagsIn C.uint64_t, flagsOut *C.uint64_t, displayLinkContext unsafe.Pointer) C.int {
cgoHandle := (*cgo.Handle)(displayLinkContext)
view := cgoHandle.Value().(*view)
view.fence.advance()
return 0
}
func (v *view) nextDrawable() ca.MetalDrawable {
if v.metalDisplayLink != 0 {
if v.drawableTimer == nil {
v.drawableTimer = time.NewTimer(time.Second)
} else {
v.drawableTimer.Reset(time.Second)
}
defer v.drawableTimer.Stop()
select {
case d := <-v.drawableCh:
return d
case <-v.drawableTimer.C:
// This happens when the main thread needs to execute the notification observer callback.
return ca.MetalDrawable{}
}
}
v.waitForDisplayLinkOutputCallback()
d, err := v.ml.NextDrawable()
if err != nil {
// Drawable is nil. This can happen at the initial state. Let's wait and see.
return ca.MetalDrawable{}
}
return d
}
func (v *view) finishDrawableUsage() {
if v.metalDisplayLink != 0 {
v.drawableDoneCh <- struct{}{}
return
}
}

View File

@@ -265,9 +265,11 @@ func (g *Graphics) flushCommandBufferIfNeeded(present bool) {
g.flushRenderCommandEncoderIfNeeded()
var presented bool
if present && g.screenDrawable != (ca.MetalDrawable{}) {
g.cb.PresentDrawable(g.screenDrawable)
g.screenDrawable = ca.MetalDrawable{}
presented = true
}
g.cb.Commit()
@@ -278,6 +280,10 @@ func (g *Graphics) flushCommandBufferIfNeeded(present bool) {
g.tmpTextures = g.tmpTextures[:0]
g.cb = mtl.CommandBuffer{}
if presented {
g.view.finishDrawableUsage()
}
}
func (g *Graphics) checkSize(width, height int) {
@@ -868,6 +874,9 @@ func (i *Image) mtlTexture() mtl.Texture {
// After nextDrawable, it is expected some command buffers are completed.
g.gcBuffers()
}
if g.screenDrawable == (ca.MetalDrawable{}) {
return mtl.Texture{}
}
return g.screenDrawable.Texture()
}
return i.texture

View File

@@ -17,7 +17,10 @@ package metal
import (
"runtime/cgo"
"sync"
"time"
"github.com/ebitengine/purego/objc"
"github.com/hajimehoshi/ebiten/v2/internal/cocoa"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/ca"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/mtl"
@@ -41,7 +44,20 @@ type view struct {
once sync.Once
displayLink uintptr
caDisplayLink uintptr
metalDisplayLink uintptr
// The following members are used only with CAMetalDisplayLink.
drawableCh chan ca.MetalDrawable
drawableDoneCh chan struct{}
drawableTimer *time.Timer
prevMetalDisplayLink chan uintptr
notificatioObserver objc.ID
metalDisplayLinkRunLoop cocoa.NSRunLoop
metalDisplayLinkReleaseBlock objc.Block
meltaDisplayLinkRecreateBlock objc.Block
// The following members are used only with CADisplayLink.
handleToSelf cgo.Handle
fence *fence
}
@@ -97,7 +113,9 @@ func (v *view) initialize(device mtl.Device, colorSpace graphicsdriver.ColorSpac
v.ml.SetMaximumDrawableCount(maximumDrawableCount)
v.initializeOS()
if err := v.initializeOS(); err != nil {
return err
}
return nil
}

View File

@@ -75,6 +75,11 @@ func (v *view) nextDrawable() ca.MetalDrawable {
return d
}
func (v *view) initializeOS() {
func (v *view) finishDrawableUsage() {
// Do nothing.
}
func (v *view) initializeOS() error {
// Do nothing.
return nil
}

View File

@@ -16,19 +16,10 @@
package metal
// #include <CoreVideo/CVDisplayLink.h>
//
// int ebitengine_DisplayLinkOutputCallback(CVDisplayLinkRef displayLinkRef, CVTimeStamp* inNow, CVTimeStamp* inOutputTime, uint64_t flagsIn, uint64_t* flagsOut, void* displayLinkContext);
import "C"
import (
"runtime/cgo"
"unsafe"
"github.com/ebitengine/purego/objc"
"github.com/hajimehoshi/ebiten/v2/internal/cocoa"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/ca"
"github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver/metal/mtl"
)
@@ -62,52 +53,20 @@ const (
resourceStorageMode = mtl.ResourceStorageModeManaged
)
func (v *view) initializeOS() {
v.fence = newFence()
// TODO: CVDisplayLink APIs are deprecated in macOS 10.15 and later.
// Use new APIs like NSView.displayLink(target:selector:).
var displayLinkRef C.CVDisplayLinkRef
if ret := C.CVDisplayLinkCreateWithActiveCGDisplays(&displayLinkRef); ret != kCVReturnSuccess {
// Failed to get the display link, so proceed without it.
return
func (v *view) initializeOS() error {
if err := v.initDisplayLink(); err != nil {
return err
}
v.handleToSelf = cgo.NewHandle(v)
C.CVDisplayLinkSetOutputCallback(displayLinkRef, C.CVDisplayLinkOutputCallback(C.ebitengine_DisplayLinkOutputCallback), unsafe.Pointer(&v.handleToSelf))
C.CVDisplayLinkStart(displayLinkRef)
v.displayLink = uintptr(displayLinkRef)
return nil
}
func (v *view) waitForDisplayLinkOutputCallback() {
if v.displayLink == 0 {
if v.caDisplayLink == 0 && v.metalDisplayLink == 0 {
return
}
if v.vsyncDisabled {
if v.caDisplayLink == 0 && v.vsyncDisabled {
// TODO: nextDrawable still waits for the next drawable available, so this should be fixed not to wait.
return
}
v.fence.wait()
}
//export ebitengine_DisplayLinkOutputCallback
func ebitengine_DisplayLinkOutputCallback(displayLinkRef C.CVDisplayLinkRef, inNow, inOutputTime *C.CVTimeStamp, flagsIn C.uint64_t, flagsOut *C.uint64_t, displayLinkContext unsafe.Pointer) C.int {
cgoHandle := (*cgo.Handle)(displayLinkContext)
view := cgoHandle.Value().(*view)
view.fence.advance()
return 0
}
func (v *view) nextDrawable() ca.MetalDrawable {
// TODO: Use CAMetalDisplayLink if available.
v.waitForDisplayLinkOutputCallback()
d, err := v.ml.NextDrawable()
if err != nil {
// Drawable is nil. This can happen at the initial state. Let's wait and see.
return ca.MetalDrawable{}
}
return d
}