Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use CADisplayLink.targetTimestamp value as an argument for frameClock.sendFrame #796

Merged
merged 7 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import androidx.compose.ui.platform.*
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.uikit.*
import androidx.compose.ui.unit.*
import kotlin.math.floor
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExportObjCClass
import kotlinx.cinterop.ObjCAction
Expand All @@ -47,7 +49,6 @@ import kotlinx.cinterop.useContents
import org.jetbrains.skia.Surface
import org.jetbrains.skiko.SkikoKeyboardEvent
import org.jetbrains.skiko.SkikoPointerEvent
import org.jetbrains.skiko.currentNanoTime
import platform.CoreGraphics.CGAffineTransformIdentity
import platform.CoreGraphics.CGAffineTransformInvert
import platform.CoreGraphics.CGPoint
Expand Down Expand Up @@ -637,8 +638,16 @@ internal actual class ComposeWindow : UIViewController {
override fun retrieveCATransactionCommands(): List<() -> Unit> =
interopContext.getActionsAndClear()

override fun draw(surface: Surface) {
scene.render(surface.canvas, currentNanoTime())
override fun draw(surface: Surface, targetTimestamp: NSTimeInterval) {
// The calculation is split in two instead of
// `(targetTimestamp * 1e9).toLong()`
// to avoid losing precision for fractional part
val integral = floor(targetTimestamp)
val fractional = targetTimestamp - integral
val secondsToNanos = 1_000_000_000L
val nanos = integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong()

scene.render(surface.canvas, nanos)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import platform.UIKit.UIApplicationWillEnterForegroundNotification
import platform.darwin.*
import kotlin.math.roundToInt
import platform.Foundation.NSThread
import platform.Foundation.NSTimeInterval

private class DisplayLinkConditions(
val setPausedCallback: (Boolean) -> Unit
Expand Down Expand Up @@ -116,30 +117,39 @@ private class ApplicationStateListener(
}
}

private enum class DrawReason {
DISPLAY_LINK_CALLBACK, SYNCHRONOUS_DRAW_REQUEST
internal interface MetalRedrawerCallbacks {
/**
* Draw into a surface.
*
* @param surface The surface to be drawn.
* @param targetTimestamp Timestamp indicating the expected draw result presentation time. Implementation should forward its internal time clock to this targetTimestamp to achieve smooth visual change cadence.
*/
fun draw(surface: Surface, targetTimestamp: NSTimeInterval)

/**
* Retrieve a list of pending actions which need to be synchronized with Metal rendering using CATransaction mechanism.
*/
fun retrieveCATransactionCommands(): List<() -> Unit>
}

internal class MetalRedrawer(
private val metalLayer: CAMetalLayer,
private val drawCallback: (Surface) -> Unit,
private val retrieveCATransactionCommands: () -> List<() -> Unit>,

// Used for tests, access to NSRunLoop crashes in test environment
addDisplayLinkToRunLoop: ((CADisplayLink) -> Unit)? = null,
private val disposeCallback: (MetalRedrawer) -> Unit = { }
private val callbacks: MetalRedrawerCallbacks,
) {
// Workaround for KN compiler bug
// Type mismatch: inferred type is objcnames.protocols.MTLDeviceProtocol but platform.Metal.MTLDeviceProtocol was expected
@Suppress("USELESS_CAST")
private val device = metalLayer.device as platform.Metal.MTLDeviceProtocol?
?: throw IllegalStateException("CAMetalLayer.device can not be null")
private val queue = device.newCommandQueue() ?: throw IllegalStateException("Couldn't create Metal command queue")
private val queue = device.newCommandQueue()
?: throw IllegalStateException("Couldn't create Metal command queue")
private val context = DirectContext.makeMetal(device.objcPtr(), queue.objcPtr())
private val inflightCommandBuffers = mutableListOf<MTLCommandBufferProtocol>()
private var lastRenderTimestamp: NSTimeInterval = CACurrentMediaTime()

// Semaphore for preventing command buffers count more than swapchain size to be scheduled/executed at the same time
private val inflightSemaphore = dispatch_semaphore_create(metalLayer.maximumDrawableCount.toLong())
private val inflightSemaphore =
dispatch_semaphore_create(metalLayer.maximumDrawableCount.toLong())

var isForcedToPresentWithTransactionEveryFrame = false

Expand All @@ -159,6 +169,9 @@ internal class MetalRedrawer(
displayLinkConditions.needsToBeProactive = value
}

/**
* null after [dispose] call
*/
private var caDisplayLink: CADisplayLink? = CADisplayLink.displayLinkWithTarget(
target = DisplayLinkProxy {
this.handleDisplayLinkTick()
Expand All @@ -184,25 +197,21 @@ internal class MetalRedrawer(
}

init {
val caDisplayLink = caDisplayLink ?: throw IllegalStateException("caDisplayLink is null during redrawer init")
val caDisplayLink = caDisplayLink
?: throw IllegalStateException("caDisplayLink is null during redrawer init")

// UIApplication can be in UIApplicationStateInactive state (during app launch before it gives control back to run loop)
// and won't receive UIApplicationWillEnterForegroundNotification
// so we compare the state with UIApplicationStateBackground instead of UIApplicationStateActive
displayLinkConditions.isApplicationActive = UIApplication.sharedApplication.applicationState != UIApplicationState.UIApplicationStateBackground
displayLinkConditions.isApplicationActive =
UIApplication.sharedApplication.applicationState != UIApplicationState.UIApplicationStateBackground

if (addDisplayLinkToRunLoop == null) {
caDisplayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoop.mainRunLoop.currentMode)
} else {
addDisplayLinkToRunLoop.invoke(caDisplayLink)
}
caDisplayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoop.mainRunLoop.currentMode)
}

fun dispose() {
check(caDisplayLink != null) { "MetalRedrawer.dispose() was called more than once" }

disposeCallback(this)

applicationStateListener.dispose()

caDisplayLink?.invalidate()
Expand All @@ -224,25 +233,27 @@ internal class MetalRedrawer(
if (displayLinkConditions.needsRedrawOnNextVsync) {
displayLinkConditions.needsRedrawOnNextVsync = false

draw(DrawReason.DISPLAY_LINK_CALLBACK)
val targetTimestamp = caDisplayLink?.targetTimestamp ?: return

draw(waitUntilCompletion = false, targetTimestamp)
}
}

/**
* Immediately dispatch draw and block the thread until it's finished and presented on the screen.
*/
fun drawSynchronously() {
draw(DrawReason.SYNCHRONOUS_DRAW_REQUEST)
if (caDisplayLink == null) {
return
}

draw(waitUntilCompletion = true, CACurrentMediaTime())
}

private fun draw(reason: DrawReason) {
private fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) {
check(NSThread.isMainThread)

if (caDisplayLink == null) {
// TODO: anomaly, log
// Logger.warn { "caDisplayLink callback called after it was invalidated " }
return
}
lastRenderTimestamp = maxOf(targetTimestamp, lastRenderTimestamp)

autoreleasepool {
val (width, height) = metalLayer.drawableSize.useContents {
Expand All @@ -264,7 +275,8 @@ internal class MetalRedrawer(
return@autoreleasepool
}

val renderTarget = BackendRenderTarget.makeMetal(width, height, metalDrawable.texture.objcPtr())
val renderTarget =
BackendRenderTarget.makeMetal(width, height, metalDrawable.texture.objcPtr())

val surface = Surface.makeFromBackendRenderTarget(
context,
Expand All @@ -285,11 +297,12 @@ internal class MetalRedrawer(
}

surface.canvas.clear(Color.WHITE)
drawCallback(surface)
callbacks.draw(surface, lastRenderTimestamp)
surface.flushAndSubmit()

val caTransactionCommands = retrieveCATransactionCommands()
val presentsWithTransaction = isForcedToPresentWithTransactionEveryFrame || caTransactionCommands.isNotEmpty()
val caTransactionCommands = callbacks.retrieveCATransactionCommands()
val presentsWithTransaction =
isForcedToPresentWithTransactionEveryFrame || caTransactionCommands.isNotEmpty()

metalLayer.presentsWithTransaction = presentsWithTransaction

Expand Down Expand Up @@ -327,7 +340,7 @@ internal class MetalRedrawer(

inflightCommandBuffers.add(commandBuffer)

if (reason == DrawReason.SYNCHRONOUS_DRAW_REQUEST) {
if (waitUntilCompletion) {
commandBuffer.waitUntilCompleted()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ internal interface SkikoUIViewDelegate {

fun retrieveCATransactionCommands(): List<() -> Unit>

fun draw(surface: Surface)
fun draw(surface: Surface, targetTimestamp: NSTimeInterval)
}

@Suppress("CONFLICTING_OVERLOADS")
Expand All @@ -78,11 +78,13 @@ internal class SkikoUIView : UIView, UIKeyInputProtocol, UITextInputProtocol {
private var _currentTextMenuActions: TextActions? = null
private val _redrawer: MetalRedrawer = MetalRedrawer(
_metalLayer,
drawCallback = { surface: Surface ->
delegate?.draw(surface)
},
retrieveCATransactionCommands = {
delegate?.retrieveCATransactionCommands() ?: listOf()
callbacks = object : MetalRedrawerCallbacks {
override fun draw(surface: Surface, targetTimestamp: NSTimeInterval) {
delegate?.draw(surface, targetTimestamp)
}

override fun retrieveCATransactionCommands(): List<() -> Unit> =
delegate?.retrieveCATransactionCommands() ?: listOf()
}
)

Expand Down