Skip to content

Instantly share code, notes, and snippets.

@iby
Last active June 6, 2023 12:20
Show Gist options
  • Save iby/7f4168df16b8bc170ef587344b6c1444 to your computer and use it in GitHub Desktop.
Save iby/7f4168df16b8bc170ef587344b6c1444 to your computer and use it in GitHub Desktop.
Rendering animated CALayer off-screen using CARenderer with MTLTexture, https://stackoverflow.com/q/56150363/458356
import AppKit
import Metal
import QuartzCore
let view = NSView(frame: CGRect(x: 0, y: 0, width: 600, height: 400))
let circle = NSView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
circle.wantsLayer = true
circle.layer?.backgroundColor = NSColor.red.cgColor
circle.layer?.cornerRadius = 25
view.wantsLayer = true
view.addSubview(circle)
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, width: 600, height: 400, mipmapped: false)
textureDescriptor.usage = [MTLTextureUsage.shaderRead, .shaderWrite, .renderTarget]
let device = MTLCreateSystemDefaultDevice()!
let texture: MTLTexture = device.makeTexture(descriptor: textureDescriptor)!
let queue: MTLCommandQueue = device.makeCommandQueue()!
let context = CIContext(mtlDevice: device)
let renderer = CARenderer(mtlTexture: texture)
var passDescriptor: MTLRenderPassDescriptor = MTLRenderPassDescriptor()
passDescriptor.colorAttachments[0].texture = texture
passDescriptor.colorAttachments[0].storeAction = .store
passDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0.25)
passDescriptor.colorAttachments[0].loadAction = .clear
renderer.layer = view.layer
renderer.bounds = view.frame
let outputURL: URL = try! FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("Off-screen Render")
try? FileManager.default.removeItem(at: outputURL)
try! FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true, attributes: nil)
var frameNumber: Int = 0
func render() {
Swift.print("Rendering frame #\(frameNumber)…")
let renderCommandBuffer: MTLCommandBuffer = queue.makeCommandBuffer()!
let renderCommandEncoder: MTLRenderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: passDescriptor)!
renderCommandEncoder.endEncoding()
renderCommandBuffer.commit()
renderCommandBuffer.waitUntilCompleted()
renderer.beginFrame(atTime: CACurrentMediaTime(), timeStamp: nil)
renderer.addUpdate(renderer.bounds)
renderer.render()
renderer.endFrame()
let blitCommandBuffer: MTLCommandBuffer = queue.makeCommandBuffer()!
let blitCommandEncoder: MTLBlitCommandEncoder = blitCommandBuffer.makeBlitCommandEncoder()!
blitCommandEncoder.synchronize(resource: texture)
blitCommandEncoder.endEncoding()
blitCommandBuffer.commit()
blitCommandBuffer.waitUntilCompleted()
let ciImage: CIImage = CIImage(mtlTexture: texture)!
let cgImage: CGImage = context.createCGImage(ciImage, from: ciImage.extent)!
let url: URL = outputURL.appendingPathComponent("frame-\(frameNumber).png")
let destination: CGImageDestination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypePNG, 1, nil)!
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else { fatalError() }
frameNumber += 1
}
var timer: Timer?
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.25
view.animator().frame.origin = CGPoint(x: 550, y: 350)
}, completionHandler: {
timer?.invalidate()
render()
Swift.print("Finished off-screen rendering of \(frameNumber) frames in \(outputURL.path)…")
})
// Make the first render immediately after the animation start and after it completes. For the purpose
// of this demo timer is used instead of display link.
render() // Fails to render first frame.
Timer.scheduledTimer(withTimeInterval: 0, repeats: false, block: { _ in render() }) // Renders the second frame.
timer = Timer.scheduledTimer(withTimeInterval: 1 / 30, repeats: true, block: { _ in render() })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment