Edited at

【iOS, Swift】Metal躓きポイント

※この記事は随時追加更新していきます。

AppleのGPU shader言語であるMetalを使っていて躓いたところについて色々と覚え書きです。

この記事は (現時点で) 以下の構成です。


  • MTLTextureをdeep copyする方法

  • MTLTextureをCVPixelBufferに変換する方法

  • Metalで処理後の動画を保存する方法


MTLTextureをdeep copyする

MTLTextureオブジェクトはクラスインスタンスなので参照型です。CVPixelBufferなどの値型とは異なり複製には注意が必要です。

MTLTextureクラスには値渡しのメソッドはなく、したがってshallow copyを避けるためには自前で値渡しもしくは新規生成のコードを書くしかありません。

実際にはMTLTextureを別の画像オブジェクトに変換して、それを使って新たなMTLTextureを生成するのが良いと思います。

各種画像クラスを用いてMTLTextureクラスを初期化できますが、筆者はCGImageを使うのが最も楽であると思っています。

// MTLDeviceは別途初期化された状態を想定。

// device: MTLDevice

// MTLTextureからCIImage生成。画像の向きを調整。
let textureLoader = MTKTextureLoader(device: device)
let textureLoaderOptions = [
MTKTextureLoader.Option.textureUsage: NSNumber(value: MTLTextureUsage.shaderRead.rawValue),
MTKTextureLoader.Option.textureStorageMode: NSNumber(value: MTLStorageMode.`private`.rawValue)
]

// CIImageに変換して画像の向きを整える。
let ciImage = CIImage(mtlTexture: texture!, options: nil)!
.transformed(by: CGAffineTransform(scaleX: 1, y: -1)
.translatedBy(x: 0, y: CGFloat(alphaTexture!.height)))

// CIImage -> CGImage
let ciContext = CIContext()
guard let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent) else {return}

// Create new MTLTexture
let newTex = try? textureLoader.newTexture(cgImage: cgImage, options: textureLoaderOptions)


MTLTexture -> CVPixelBuffer

CVPixelBufferLockBaseAddress(pixelBuffer, [])

let pixelBufferBytes = CVPixelBufferGetBaseAddress(pixelBuffer)!
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)

let region = MTLRegionMake2D(0, 0, texture.width, texture.height)

// 以下を実行すれば、pixelBufferに画像データが入る。
texture.getBytes(pixelBufferBytes, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)

CVPixelBufferUnlockBaseAddress(pixelBuffer, [])


Metalで処理後に動画を保存する

AVAssetWriterを使います。

ここでは下記のようにVideoSaverクラスを定義します。

import AVFoundation

import Metal
import MetalKit
import Photos

class VideoSaver {
var isRecording = false
var recordingStartTime = TimeInterval(0)

private var url: URL?

private var assetWriter: AVAssetWriter
private var assetWriterVideoInput: AVAssetWriterInput
private var assetWriterPixelBufferInput: AVAssetWriterInputPixelBufferAdaptor

// AVAssetWriterを初期化
init?(outputURL url: URL, size: CGSize) {
do {
assetWriter = try AVAssetWriter(outputURL: url, fileType: AVFileType.mp4)
} catch {
return nil
}
self.url = url
let outputSettings: [String: Any] = [ AVVideoCodecKey : AVVideoCodecType.h264, AVVideoWidthKey : size.width, AVVideoHeightKey : size.height ]

assetWriterVideoInput = AVAssetWriterInput(mediaType: AVMediaType.video, outputSettings: outputSettings)
assetWriterVideoInput.expectsMediaDataInRealTime = true

let sourcePixelBufferAttributes: [String: Any] = [kCVPixelBufferPixelFormatTypeKey as String : kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String : size.width, kCVPixelBufferHeightKey as String : size.height]

assetWriterPixelBufferInput = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: assetWriterVideoInput, sourcePixelBufferAttributes: sourcePixelBufferAttributes)
assetWriter.add(assetWriterVideoInput)
}

// UIButtonなどから呼ぶ録画開始メソッド
func startRecording() {
assetWriter.startWriting()
assetWriter.startSession(atSourceTime: CMTime.zero)

recordingStartTime = CACurrentMediaTime()
isRecording = true
}

// UIButtonなどから呼ぶ録画終了メソッド
func endRecording(_ completionHandler: @escaping () -> ()) {
isRecording = false

assetWriterVideoInput.markAsFinished()
assetWriter.finishWriting(completionHandler: completionHandler)

outputVideos()
}

// フレームをアプリローカルの保存先パスに書き込むメソッド。後述。
func writeFrame(forTexture texture: MTLTexture) {
/*
後述
*/

}

// PHPhotoLibraryを用いて、アプリローカルの保存先からiOSのGalleryへファイルを移す。
private func outputVideos() {
let url = self.url

PHPhotoLibrary.shared().performChanges({
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url!)
}) { (isCompleted, error) in
if isCompleted {
do {
try FileManager.default.removeItem(atPath: url!.path)
print("ファイル移動成功 : \(url!.lastPathComponent)")
}
catch {
print("ファイルコピー成功、ファイル移動失敗 : \(url!.lastPathComponent)")
}
}
else {
print("ファイルコピー失敗 : \(url!.lastPathComponent)")
}
}
}

作った.mp4動画はアプリのローカルディレクトリに一時保存するようにします。


録画開始・終了メソッド

録画開始・終了は以下のように呼べば良い。

    func takeVideo() {

if !isRec {
guard let url = videoFileLocation() else {return}
let size = CGSize(width: cTexture!.width, height: cTexture!.height)
self.videoSaver = VideoSaver(outputURL: url, size: size)
videoSaver!.startRecording()

isRec = true
} else {
isRec = false
videoSaver!.endRecording {
print("Save Finished")
}
}
}


textureを取得してAVAssetWriterに渡す

textureをストリームで得るために以下の位置でcurrentDrawableからMTLTextureを取得、を取得するようにします。

/*

下記Metal描画パラメータの各種設定はすべて省略

let view = self.view as? MTKView
view.device = MTLCreateSystemDefaultDevice()
view.delegate = self

let renderDestination = view
let renderPassDescriptor = renderDestination.currentRenderPassDescriptor
let currentDrawable = renderDestination.currentDrawable

let device = view.device
let commandQueue = device.makeCommandQueue()
let commandBuffer = commandQueue.makeCommandBuffer()
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
*/

renderEncoder.endEncoding()

// .endEncoding() ~ .present() の間に、以下を挿入。
self.cTexture = currentDrawable.texture
if self.isRec {
commandBuffer.addCompletedHandler { commandBuffer in
self.videoSaver!.writeFrame(forTexture: self.cTexture!)
}
}

commandBuffer.present(currentDrawable)
commandBuffer.commit()


textureをアプリローカルに一時保存

最後に、以下がアプリローカルへのフレームデータ書き込みメソッドです。

    func writeFrame(forTexture texture: MTLTexture) {

if !isRecording {return}

while !assetWriterVideoInput.isReadyForMoreMediaData {}

// MTLTexure -> CVPixelBuffer -> AVAssetWriter's pixelBufferPool
guard let pixelBufferPool = assetWriterPixelBufferInput.pixelBufferPool else {return}
var maybePixelBuffer: CVPixelBuffer? = nil
let status = CVPixelBufferPoolCreatePixelBuffer(nil, pixelBufferPool, &maybePixelBuffer)
if status != kCVReturnSuccess {return}

guard let pixelBuffer = maybePixelBuffer else { return }
CVPixelBufferLockBaseAddress(pixelBuffer, [])
let pixelBufferBytes = CVPixelBufferGetBaseAddress(pixelBuffer)!
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let region = MTLRegionMake2D(0, 0, texture.width, texture.height)
texture.getBytes(pixelBufferBytes, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)

// AVAssetWriterへCVPixelBufferを追加。
let frameTime = CACurrentMediaTime() - recordingStartTime
let presentationTime = CMTimeMakeWithSeconds(frameTime, preferredTimescale: 240)
assetWriterPixelBufferInput.append(pixelBuffer, withPresentationTime: presentationTime)

CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
}


終わりに

ご参考になれば幸いです!

改善方法やご意見などあれば、どしどしコメント下さい!