Unverified Commit 39fb88f6 authored by Elijah Semyonov's avatar Elijah Semyonov Committed by GitHub

Introduce vsync-based and overcommitment throttling to metal redrawer on macOS (#753)

* Add repro

* Add ignore

* Remove redundant retain

* Add DisplayLinkThrottler implementation

* Create Kotlin wrapper and JNI wrapper.

* Integrate throttler into redrawer

* Release CVDisplayLink in case of error

* Fix wrong screen dictionary retrieval.

* Remove temporary changes.

* Rename test.

* Add overcommitment prevention mechanism (#754)

* Add overcommitment prevention mechanism

* Replace hardcoded value with CAMetalLayer property

* Update skiko/src/awtMain/objectiveC/macos/MetalContextHandler.mm
Co-authored-by: 's avatarIgor Demin <igordmn@users.noreply.github.com>

---------
Co-authored-by: 's avatarIgor Demin <igordmn@users.noreply.github.com>

* Remove _displayLinkOk (#756)

* Remove _displayLinkOk

* Discussions

* Fix build

---------
Co-authored-by: 's avatarIgor Demin <igordmn@users.noreply.github.com>
parent 0c89dfa7
package org.jetbrains.skiko.redrawer
import org.jetbrains.skiko.Library
internal class DisplayLinkThrottler {
private val implPtr = create()
internal fun dispose() = dispose(implPtr)
/*
* Creates a DisplayLink if needed with refresh rate matching NSScreen of NSWindow passed in [windowPtr].
* If DisplayLink is already active, blocks until next vsync for physical screen of NSWindow passed in [windowPtr].
*/
internal fun waitVSync(windowPtr: Long) = waitVSync(implPtr, windowPtr)
private external fun create(): Long
private external fun dispose(implPtr: Long)
private external fun waitVSync(implPtr: Long, windowPtr: Long)
companion object {
init {
Library.load()
}
}
}
\ No newline at end of file
...@@ -62,6 +62,7 @@ internal class MetalRedrawer( ...@@ -62,6 +62,7 @@ internal class MetalRedrawer(
} }
private val adapter = chooseMetalAdapter(properties.adapterPriority) private val adapter = chooseMetalAdapter(properties.adapterPriority)
private val displayLinkThrottler = DisplayLinkThrottler()
init { init {
onDeviceChosen(adapter.name) onDeviceChosen(adapter.name)
...@@ -88,11 +89,17 @@ internal class MetalRedrawer( ...@@ -88,11 +89,17 @@ internal class MetalRedrawer(
onContextInit() onContextInit()
} }
fun drawSync() {
layer.update(System.nanoTime())
performDraw()
}
override fun dispose() = synchronized(drawLock) { override fun dispose() = synchronized(drawLock) {
frameDispatcher.cancel() frameDispatcher.cancel()
contextHandler.dispose() contextHandler.dispose()
disposeDevice(device.ptr) disposeDevice(device.ptr)
adapter.dispose() adapter.dispose()
displayLinkThrottler.dispose()
_device = null _device = null
super.dispose() super.dispose()
} }
...@@ -142,6 +149,11 @@ internal class MetalRedrawer( ...@@ -142,6 +149,11 @@ internal class MetalRedrawer(
private fun performDraw() = synchronized(drawLock) { private fun performDraw() = synchronized(drawLock) {
if (!isDisposed) { if (!isDisposed) {
// Wait for vsync because:
// - macOS drops the second/next drawables if they are sent in the same vsync
// - it makes frames consistent and limits FPS
displayLinkThrottler.waitVSync(windowPtr = layer.windowHandle)
val handle = startRendering() val handle = startRendering()
try { try {
contextHandler.draw() contextHandler.draw()
......
#ifdef SK_METAL
#import <jawt.h>
#import <jawt_md.h>
#import <QuartzCore/QuartzCore.h>
#import <AppKit/AppKit.h>
#import <stdatomic.h>
@interface DisplayLinkThrottler : NSObject
- (void)onVSync;
@end
static CVReturn displayLinkCallback(CVDisplayLinkRef displayLink, const CVTimeStamp *now, const CVTimeStamp *outputTime, CVOptionFlags flagsIn, CVOptionFlags *flagsOut, void *displayLinkContext) {
DisplayLinkThrottler *throttler = (__bridge DisplayLinkThrottler *)displayLinkContext;
[throttler onVSync];
return kCVReturnSuccess;
}
@implementation DisplayLinkThrottler {
NSScreen *_displayLinkScreen;
CVDisplayLinkRef _displayLink;
NSConditionLock *_vsyncConditionLock;
}
- (instancetype)init {
self = [super init];
if (self) {
_displayLinkScreen = nil;
_displayLink = nil;
_vsyncConditionLock = [[NSConditionLock alloc] initWithCondition: 1];
}
return self;
}
- (void)onVSync {
/// Lock condition lock and immediately unlock setting condition variable to 1 (can render now)
[_vsyncConditionLock lock];
[_vsyncConditionLock unlockWithCondition:1];
}
- (void)waitVSync {
/// If display link construction was corrupted, don't perform any waiting
if (!_displayLink) {
return;
}
/// Wait until `onVSync` signals 1, then immediately lock, set it to 0 and unlock again.
[_vsyncConditionLock lockWhenCondition:1];
[_vsyncConditionLock unlockWithCondition:0];
}
- (void)invalidateDisplayLink {
if (_displayLink) {
CVDisplayLinkStop(_displayLink);
CVDisplayLinkRelease(_displayLink);
_displayLink = nil;
_displayLinkScreen = nil;
}
}
- (void)setupDisplayLinkForWindow:(NSWindow *)window {
NSScreen *screen = window.screen;
if (!screen) {
/// window is not yet attached to screen (or window is nil)
return;
}
if ([screen isEqualTo: _displayLinkScreen]) {
/// display link is already active for this screen, do nothing
return;
}
[self invalidateDisplayLink];
NSDictionary* screenDescription = [screen deviceDescription];
NSNumber* screenID = [screenDescription objectForKey:@"NSScreenNumber"];
_displayLink = [self createDisplayLinkForScreen:screenID];
_displayLinkScreen = screen;
}
- (CVDisplayLinkRef)createDisplayLinkForScreen:(NSNumber *)screenID {
CVReturn result;
CVDisplayLinkRef displayLink;
result = CVDisplayLinkCreateWithCGDisplay([screenID unsignedIntValue], &displayLink);
if (result != kCVReturnSuccess) {
return nil;
}
result = CVDisplayLinkSetOutputCallback(displayLink, &displayLinkCallback, (__bridge void *)(self));
if (result != kCVReturnSuccess) {
CVDisplayLinkRelease(displayLink);
return nil;
}
result = CVDisplayLinkStart(displayLink);
if (result != kCVReturnSuccess) {
CVDisplayLinkRelease(displayLink);
return nil;
}
return displayLink;
}
- (void)dealloc {
[self invalidateDisplayLink];
}
@end
extern "C" {
JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_redrawer_DisplayLinkThrottler_create(JNIEnv *env, jobject obj) {
DisplayLinkThrottler *throttler = [[DisplayLinkThrottler alloc] init];
return (jlong) (__bridge_retained void *) throttler;
}
JNIEXPORT void JNICALL Java_org_jetbrains_skiko_redrawer_DisplayLinkThrottler_dispose(JNIEnv *env, jobject obj, jlong throttlerPtr) {
DisplayLinkThrottler *throttler = (__bridge_transfer DisplayLinkThrottler *) (void *) throttlerPtr;
/// throttler will be released by ARC and deallocated in the end of this scope.
}
JNIEXPORT void JNICALL Java_org_jetbrains_skiko_redrawer_DisplayLinkThrottler_waitVSync(JNIEnv *env, jobject obj, jlong throttlerPtr, jlong windowPtr) {
DisplayLinkThrottler *throttler = (__bridge DisplayLinkThrottler *) (void *) throttlerPtr;
NSWindow *window = (__bridge NSWindow *) (void *) windowPtr;
[throttler setupDisplayLinkForWindow:window];
[throttler waitVSync];
}
}
#endif // SK_METAL
\ No newline at end of file
...@@ -32,8 +32,14 @@ JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_context_MetalContextHandler_mak ...@@ -32,8 +32,14 @@ JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_context_MetalContextHandler_mak
MetalDevice *device = (__bridge MetalDevice *) (void *) devicePtr; MetalDevice *device = (__bridge MetalDevice *) (void *) devicePtr;
GrBackendRenderTarget* renderTarget = NULL; GrBackendRenderTarget* renderTarget = NULL;
/// If we have more than `maximumDrawableCount` command buffers inflight, wait until one of them finishes work.
dispatch_semaphore_wait(device.inflightSemaphore, DISPATCH_TIME_FOREVER);
id<CAMetalDrawable> currentDrawable = [device.layer nextDrawable]; id<CAMetalDrawable> currentDrawable = [device.layer nextDrawable];
if (!currentDrawable) { if (!currentDrawable) {
/// Signal semaphore immediately, no command buffer will be commited
dispatch_semaphore_signal(device.inflightSemaphore);
return NULL; return NULL;
} }
device.drawableHandle = currentDrawable; device.drawableHandle = currentDrawable;
...@@ -56,6 +62,10 @@ JNIEXPORT void JNICALL Java_org_jetbrains_skiko_context_MetalContextHandler_fini ...@@ -56,6 +62,10 @@ JNIEXPORT void JNICALL Java_org_jetbrains_skiko_context_MetalContextHandler_fini
id<MTLCommandBuffer> commandBuffer = [device.queue commandBuffer]; id<MTLCommandBuffer> commandBuffer = [device.queue commandBuffer];
commandBuffer.label = @"Present"; commandBuffer.label = @"Present";
[commandBuffer presentDrawable:currentDrawable]; [commandBuffer presentDrawable:currentDrawable];
[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
/// commands have completed, allow next waiting (if any) to start encoding new work to gpu
dispatch_semaphore_signal(device.inflightSemaphore);
}];
[commandBuffer commit]; [commandBuffer commit];
device.drawableHandle = nil; device.drawableHandle = nil;
} }
......
...@@ -9,10 +9,11 @@ ...@@ -9,10 +9,11 @@
@interface MetalDevice : NSObject @interface MetalDevice : NSObject
@property (weak) CALayer *container; @property (weak) CALayer *container;
@property (retain, strong) AWTMetalLayer *layer; @property (strong) AWTMetalLayer *layer;
@property (retain, strong) id<MTLDevice> adapter; @property (strong) id<MTLDevice> adapter;
@property (retain, strong) id<MTLCommandQueue> queue; @property (strong) id<MTLCommandQueue> queue;
@property (retain, strong) id<CAMetalDrawable> drawableHandle; @property (strong) id<CAMetalDrawable> drawableHandle;
@property (strong) dispatch_semaphore_t inflightSemaphore;
@end @end
......
...@@ -104,6 +104,9 @@ JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_redrawer_MetalRedrawer_createMe ...@@ -104,6 +104,9 @@ JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_redrawer_MetalRedrawer_createMe
device.layer.opaque = NO; device.layer.opaque = NO;
device.layer.framebufferOnly = NO; device.layer.framebufferOnly = NO;
/// max inflight command buffers count matches swapchain size to avoid overcommitment
device.inflightSemaphore = dispatch_semaphore_create(device.layer.maximumDrawableCount);
if (transparency) if (transparency)
{ {
NSWindow* window = (__bridge NSWindow*) (void *) windowPtr; NSWindow* window = (__bridge NSWindow*) (void *) windowPtr;
......
...@@ -2,6 +2,7 @@ package org.jetbrains.skiko ...@@ -2,6 +2,7 @@ package org.jetbrains.skiko
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.jetbrains.skia.Canvas import org.jetbrains.skia.Canvas
import org.jetbrains.skia.FontMgr import org.jetbrains.skia.FontMgr
...@@ -12,6 +13,7 @@ import org.jetbrains.skia.paragraph.ParagraphBuilder ...@@ -12,6 +13,7 @@ import org.jetbrains.skia.paragraph.ParagraphBuilder
import org.jetbrains.skia.paragraph.ParagraphStyle import org.jetbrains.skia.paragraph.ParagraphStyle
import org.jetbrains.skia.paragraph.TextStyle import org.jetbrains.skia.paragraph.TextStyle
import org.jetbrains.skiko.context.JvmContextHandler import org.jetbrains.skiko.context.JvmContextHandler
import org.jetbrains.skiko.redrawer.MetalRedrawer
import org.jetbrains.skiko.redrawer.Redrawer import org.jetbrains.skiko.redrawer.Redrawer
import org.jetbrains.skiko.util.ScreenshotTestRule import org.jetbrains.skiko.util.ScreenshotTestRule
import org.jetbrains.skiko.util.UiTestScope import org.jetbrains.skiko.util.UiTestScope
...@@ -19,14 +21,13 @@ import org.jetbrains.skiko.util.UiTestWindow ...@@ -19,14 +21,13 @@ import org.jetbrains.skiko.util.UiTestWindow
import org.jetbrains.skiko.util.uiTest import org.jetbrains.skiko.util.uiTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Ignore
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Color import java.awt.Color
import java.awt.Dimension import java.awt.Dimension
import java.awt.event.ComponentAdapter import java.awt.event.*
import java.awt.event.ComponentEvent
import java.awt.event.WindowEvent
import javax.swing.JFrame import javax.swing.JFrame
import javax.swing.JLayeredPane import javax.swing.JLayeredPane
import javax.swing.JPanel import javax.swing.JPanel
...@@ -35,6 +36,8 @@ import javax.swing.WindowConstants ...@@ -35,6 +36,8 @@ import javax.swing.WindowConstants
import kotlin.random.Random import kotlin.random.Random
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@Suppress("BlockingMethodInNonBlockingContext", "SameParameterValue") @Suppress("BlockingMethodInNonBlockingContext", "SameParameterValue")
class SkiaLayerTest { class SkiaLayerTest {
...@@ -56,6 +59,69 @@ class SkiaLayerTest { ...@@ -56,6 +59,69 @@ class SkiaLayerTest {
@get:Rule @get:Rule
val screenshots = ScreenshotTestRule() val screenshots = ScreenshotTestRule()
@OptIn(ExperimentalTime::class)
@Ignore
@Test
fun `metal drawables not lost`() = uiTest {
val window = UiTestWindow(
properties = SkiaLayerProperties(
isVsyncEnabled = true
)
)
val colors = arrayOf(
Color.RED,
Color.GREEN,
Color.BLUE,
Color.YELLOW
)
var counter1 = 0
var counter2 = 0
val paint = Paint()
try {
window.setLocation(200, 200)
window.setSize(400, 600)
window.defaultCloseOperation = WindowConstants.EXIT_ON_CLOSE
window.layer.skikoView = object : SkikoView {
override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
val c1 = counter1
val c2 = counter2
paint.color = colors[c1.mod(colors.size)].rgb
canvas.drawRect(Rect(0f, 0f, width.toFloat(), height / 2f), paint)
paint.color = colors[c2.mod(colors.size)].rgb
canvas.drawRect(Rect(0f, height / 2f, width.toFloat(), height.toFloat()), paint)
}
}
window.isVisible = true
window.addKeyListener(object : KeyAdapter() {
override fun keyTyped(e: KeyEvent?) {
launch {
val redrawer = window.layer.redrawer as MetalRedrawer
redrawer.drawSync()
counter1 += 1
redrawer.drawSync()
counter2 += 1
redrawer.drawSync()
}
}
});
window.addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) {
window.requestFocus()
}
})
delay(Duration.INFINITE)
} finally {
window.close()
}
}
@Test @Test
fun `should not leak native windows`() = uiTest { fun `should not leak native windows`() = uiTest {
assumeTrue(hostOs.isMacOS) assumeTrue(hostOs.isMacOS)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment