Unverified Commit 544b4b76 authored by Nikolay Igotti's avatar Nikolay Igotti Committed by GitHub

Fix Metal crazy rendering on invisible windows. (#89)

parent ec704f7b
...@@ -5,6 +5,7 @@ import kotlinx.coroutines.test.TestCoroutineScope ...@@ -5,6 +5,7 @@ import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class FrameDispatcherTest { class FrameDispatcherTest {
...@@ -77,7 +78,6 @@ class FrameDispatcherTest { ...@@ -77,7 +78,6 @@ class FrameDispatcherTest {
assertEquals(2, frameCount) assertEquals(2, frameCount)
} }
@Suppress("JoinDeclarationAndAssignment")
@Test @Test
fun `scheduleFrame during onFrame`() = test { fun `scheduleFrame during onFrame`() = test {
lateinit var frameDispatcher: FrameDispatcher lateinit var frameDispatcher: FrameDispatcher
...@@ -100,7 +100,6 @@ class FrameDispatcherTest { ...@@ -100,7 +100,6 @@ class FrameDispatcherTest {
assertEquals(4, frameCount) assertEquals(4, frameCount)
} }
@Suppress("JoinDeclarationAndAssignment")
@Test @Test
fun `scheduleFrame multiple times during onFrame`() = test { fun `scheduleFrame multiple times during onFrame`() = test {
lateinit var frameDispatcher: FrameDispatcher lateinit var frameDispatcher: FrameDispatcher
...@@ -125,7 +124,6 @@ class FrameDispatcherTest { ...@@ -125,7 +124,6 @@ class FrameDispatcherTest {
assertEquals(4, frameCount) assertEquals(4, frameCount)
} }
@Suppress("JoinDeclarationAndAssignment")
@Test @Test
fun `cancel coroutine scope`() = test { fun `cancel coroutine scope`() = test {
val scope = CoroutineScope(coroutineContext) val scope = CoroutineScope(coroutineContext)
...@@ -154,6 +152,39 @@ class FrameDispatcherTest { ...@@ -154,6 +152,39 @@ class FrameDispatcherTest {
assertEquals(3, frameCount) assertEquals(3, frameCount)
} }
@Test
fun `perform tasks scheduled in the frame after the frame`() {
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val history = mutableListOf<String>()
runBlocking(dispatcher) {
val job = launch {
val scope = this
lateinit var frameDispatcher: FrameDispatcher
frameDispatcher = FrameDispatcher(scope = scope) {
history.add("frame$frameCount")
if (frameCount == 0) {
scope.launch {
history.add("task")
}
frameDispatcher.scheduleFrame()
}
frameCount++
}
frameDispatcher.scheduleFrame()
}
repeat(20) {
yield()
}
job.cancel()
}
assertEquals(listOf("frame0", "task", "frame1"), history)
}
private fun test( private fun test(
block: suspend TestCoroutineScope.() -> Unit block: suspend TestCoroutineScope.() -> Unit
) = runBlockingTest { ) = runBlockingTest {
......
...@@ -134,6 +134,7 @@ native crash in SkiaWindowTest "render single window" ...@@ -134,6 +134,7 @@ native crash in SkiaWindowTest "render single window"
fun `FPS is near display refresh rate (multiple windows)`() = swingTest { fun `FPS is near display refresh rate (multiple windows)`() = swingTest {
val windows = (1..3).map { index -> val windows = (1..3).map { index ->
TestWindow(width = 40, height = 20, frameCount = 300, deviatedTerminalCount = 10).apply { TestWindow(width = 40, height = 20, frameCount = 300, deviatedTerminalCount = 10).apply {
toFront()
location = Point((index + 1) * 200, 200) location = Point((index + 1) * 200, 200)
} }
} }
......
package org.jetbrains.skiko package org.jetbrains.skiko
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
...@@ -23,13 +23,20 @@ class FrameDispatcher( ...@@ -23,13 +23,20 @@ class FrameDispatcher(
onFrame onFrame
) )
private var needFrame = CompletableDeferred<Unit>() private val frameChannel = Channel<Unit>(Channel.CONFLATED)
private var frameScheduled = false
private val job = scope.launch { private val job = scope.launch {
while (true) { while (true) {
needFrame.await() frameChannel.receive()
needFrame = CompletableDeferred() frameScheduled = false
onFrame() onFrame()
// As per `yield()` documentation:
//
// For other dispatchers (not == Unconfined) , this function calls [CoroutineDispatcher.dispatch] and
// always suspends to be resumed later regardless of the result of [CoroutineDispatcher.isDispatchNeeded].
//
// What means for Swing dispatcher we'll process all pending events and resume renderer.
yield() yield()
} }
} }
...@@ -41,12 +48,15 @@ class FrameDispatcher( ...@@ -41,12 +48,15 @@ class FrameDispatcher(
/** /**
* Schedule next frame to render in the frame loop. * Schedule next frame to render in the frame loop.
* *
* Multiple calls of scheduleFrame before beginning of the frame will cause only one onFrame. * Multiple calls of `scheduleFrame` before beginning of the frame will cause only one `onFrame`.
* *
* Multiple calls of scheduleFrame after beginning of the frame but before its ending * Multiple calls of `scheduleFrame` after beginning of the frame but before its ending
* will schedule next single onFrame after the current one. * will schedule next single `onFrame` after the current one.
*/ */
fun scheduleFrame() { fun scheduleFrame() {
needFrame.complete(Unit) if (!frameScheduled) {
frameScheduled = true
frameChannel.offer(Unit)
}
} }
} }
\ No newline at end of file
package org.jetbrains.skiko.redrawer package org.jetbrains.skiko.redrawer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.jetbrains.skija.BackendRenderTarget import org.jetbrains.skija.BackendRenderTarget
import org.jetbrains.skija.DirectContext import org.jetbrains.skija.DirectContext
import org.jetbrains.skiko.FrameDispatcher import org.jetbrains.skiko.FrameDispatcher
...@@ -19,6 +18,7 @@ internal class MetalRedrawer( ...@@ -19,6 +18,7 @@ internal class MetalRedrawer(
private var isDisposed = false private var isDisposed = false
private var disposeLock = Any() private var disposeLock = Any()
private val device = layer.backedLayer.useDrawingSurfacePlatformInfo(::createMetalDevice) private val device = layer.backedLayer.useDrawingSurfacePlatformInfo(::createMetalDevice)
private val windowHandle = layer.windowHandle
private val frameDispatcher = FrameDispatcher(Dispatchers.Swing) { private val frameDispatcher = FrameDispatcher(Dispatchers.Swing) {
update(System.nanoTime()) update(System.nanoTime())
...@@ -38,9 +38,9 @@ internal class MetalRedrawer( ...@@ -38,9 +38,9 @@ internal class MetalRedrawer(
override fun redrawImmediately() { override fun redrawImmediately() {
check(!isDisposed) check(!isDisposed)
// TODO now we wait until previous layer.draw is finished. it ends only on the next vsync. // TODO: now we wait until previous `layer.draw` is finished. it ends only on the next vsync.
// because of that we lose one frame on resize and can theoretically see very small white bars on the sides of the window // Because of that we lose one frame on resize and can theoretically see very small white bars on the sides
// to avoid this we should be able to draw in two modes: with vsync and without. // of the window to avoid this we should be able to draw in two modes: with vsync and without.
frameDispatcher.scheduleFrame() frameDispatcher.scheduleFrame()
} }
...@@ -68,6 +68,11 @@ internal class MetalRedrawer( ...@@ -68,6 +68,11 @@ internal class MetalRedrawer(
} }
} }
} }
// When window is not visible - it doesn't make sense to redraw fast to avoid battery drain.
// In theory, we could be more precise, and just suspend rendering in
// `NSWindowDidChangeOcclusionStateNotification`, but current approach seems to work as well in practise.
if (isOccluded(windowHandle))
delay(300)
} }
} }
...@@ -100,4 +105,5 @@ internal class MetalRedrawer( ...@@ -100,4 +105,5 @@ internal class MetalRedrawer(
private external fun finishFrame(device: Long) private external fun finishFrame(device: Long)
private external fun resizeLayers(device: Long, x: Int, y: Int, width: Int, height: Int) private external fun resizeLayers(device: Long, x: Int, y: Int, width: Int, height: Int)
private external fun setContentScale(device: Long, contentScale: Float) private external fun setContentScale(device: Long, contentScale: Float)
private external fun isOccluded(window: Long): Boolean
} }
...@@ -241,5 +241,11 @@ JNIEXPORT void JNICALL Java_org_jetbrains_skiko_redrawer_MetalRedrawer_disposeDe ...@@ -241,5 +241,11 @@ JNIEXPORT void JNICALL Java_org_jetbrains_skiko_redrawer_MetalRedrawer_disposeDe
[device release]; [device release];
} }
JNIEXPORT jboolean JNICALL Java_org_jetbrains_skiko_redrawer_MetalRedrawer_isOccluded(
JNIEnv *env, jobject redrawer, jlong windowPtr) {
NSWindow* window = (NSWindow*)windowPtr;
return ([window occlusionState] & NSWindowOcclusionStateVisible) == 0;
}
} // extern C } // extern C
#endif #endif
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