9

The Apple docs for CAMetalLayer state that the property presentsWithTransaction is:

A Boolean value that determines whether the layer presents its content using a Core Animation transaction

As I'm using UIKit to drive some metal animations (similar to the method Apple suggests for OpenGL in this WWDC 2012 session), I'm assuming this is the right time to enable it. I have a Metal "background" view, overlayed with some UIKit components (which also animate) and so this sounds very much like the applicable use-case:

By default [.presentsWithTransaction] is false: CAMetalLayer displays the output of a rendering pass to the display as quickly as possible and asynchronously to any Core Animation transactions. However, if your game or app combines Metal and Core Animation content, it's not guaranteed that your Metal content will arrive in the same frame as your Core Animation content. This could be an issue if, for example, your app draws UIKit content (such as labels with a target position and time) over the top of your CAMetalLayer and the two domains need to be synchronized.

Certainly, without that setting enabled, scrolling appears jerky. With presentsWithTransaction enabled I'm having some limited success but neither of two routes I've tried with the setting enabled are perfect.

The first method I've tried follows the instructions within the docs for presentsWithTransaction. So, within my MTKViewDelegate I have the following method:

func draw(in view: MTKView) {
    guard
        let commandBuffer = commandQueue.makeCommandBuffer(),
        let drawable = view.currentDrawable
    else { return }

    updateState(device: device, library: library) // update positions, etc.
    render(with: commandBuffer, in: view) // drawing code

    commandBuffer.commit()
    commandBuffer.waitUntilScheduled()
    drawable.present()
}

This mostly works fine – but so does leaving the setting off entirely. It has a tendency to de-synchronise at certain points, causing a characteristic shudder driving a scrolling animation via a UIScrollView for example. The whole idea of presentsWithTransaction is to avoid exactly this, so perhaps I'm doing something wrong here.

The second method makes use of addScheduledHandler on the command buffer:

func draw(in view: MTKView) {
    guard
        let commandBuffer = commandQueue.makeCommandBuffer(),
        let drawable = view.currentDrawable
    else { return }

    updateState(device: device, library: library) // update positions, etc.
    render(with: commandBuffer, in: view) // drawing code

    commandBuffer.addScheduledHandler { _ in
        DispatchQueue.main.async { drawable.present() }
    }
    commandBuffer.commit()
}

This method appears to stay in sync, but causes some horrendous CPU hangs (2 secs or more), especially when the app becomes active after being in the background.

Is there any way to get the best of both worlds?

Edit: 9-Dec-2018: Whilst the first method described above does seem to be preferential, it does still result in frequent de-synchronisation if there is a spike in CPU usage on the main thread – which is unavoidable in most situations.

You can tell when this happens as the draw loop becomes starved of drawables. This causes a knock-on effect, which means the drawable for the next frame is delayed also. In the Metal Instruments panel, this results in a series of 'thread blocked waiting for next drawable' warnings.

With the design above, as Metal is blocked waiting for a drawable – so is the main thread. Now touch events become delayed, resulting in a distinctive stuttering pattern for a pan gesture – even though the app is still running theoretically at a full 60fps, the blocking seems to affect the cadence at which touch events are reported – resulting in the judder effect.

A subsequent CPU spike can knock things back into sequence and the app will begin performing as normal.

Edit: 10-Dec-2018: And here's a small example project that demonstrates the issue. Create a new Xcode project copy and paste the contents of the two swift files (add a new file for the metal shader file) and run on device:

https://gist.github.com/tcldr/ee7640ccd97e5d8810af4c34cf960284

Tricky
  • 7,025
  • 5
  • 33
  • 43

1 Answers1

2

Edit 9-Dec-2018: I'm removing the accepted answer designation from this answer as this bug still appears to be at large. Whilst the below makes the bug less likely to occur, it still does occur. More info in the original question.

Original answer: This seems to work for me:

func draw(in view: MTKView) {

    updateState()  // update positions, etc.

    guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }

    autoreleasepool { render(with: commandBuffer, in: view) } // drawing code

    commandBuffer.commit()
    commandBuffer.waitUntilScheduled()
    view.currentDrawable?.present()
}

.presentsWithTransaction on MTKView is also set to true

The idea is to wait until the last possible moment to call currentDrawable which is suggested somewhere in the docs, but I can't remember where now. At a minimum, I believe it should be called after .waitUntilScheduled().

Tricky
  • 7,025
  • 5
  • 33
  • 43
  • AFAICT this is the same advice given on the Apple develops pages, and it has not worked for me so far. I know it's asking a lot, but would you consider posting somewhere a bare-bones demonstration that it works? For example, if you modified Apple's HelloTriangle program so that no glitches occurred on live resize (you can observe them by drawing the triangle toward the edge of the screen rather than centered), it would be a huge contribution to a problem many people are complaining about! Thanks much for your answer either way --- – Jonathan Zrake Jul 31 '18 at 13:11
  • 1
    I haven't seen the example you mention. There's so many variables at play. It took me a good few days of head-sctaching to realise my problem was caused by unwrapping the `currentDrawable` optional prior to calling `.waitUntilScheduled()`. It meant occasional and unpredictable contention on the drawable (which is triple-buffered, I believe). Have you checked out the video at https://developer.apple.com/videos/play/wwdc2015/610/ ? The Instruments performance tools really do help you to drill down into exactly what might be causing the stuttering. – Tricky Aug 01 '18 at 17:27
  • Just updating this to say, this *doesn't* work for me. What it has seem to have done is simply make the circumstances for this bug less likely to occur. – Tricky Dec 09 '18 at 20:57
  • 1
    @Tricky How does your render function work? For what I'm doing I'm pretty sure I need to call `commandEncoder.setTexture(drawable.texture, index: 0)` which means I need to unwrap the drawable _before_ calling `commandBuffer.commit()`. Is this not true in your `render(with:in:)` function or am I missing something? – B Roy Dawson May 03 '19 at 16:21
  • Agree with @BRoyDawson, if we are not missing something. – actual Apr 15 '22 at 18:36