※この記事は随時追加更新していきます。
AppleのGPU shader言語であるMetalを使っていて躓いたところについて色々と覚え書きです。
この記事は (現時点で) 以下の構成です。
- MTLTextureをdeep copyする方法
- MTLTextureオブジェクトのメモリ管理
- MTLTextureをCVPixelBufferに変換する方法
- MetalにおけるGlobal変数について
- bridging-headerを用いる方法
- 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(texture.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オブジェクトのメモリ管理
MTLBuffer
や MTLTexture
などのMetalにデータを渡すオブジェクトを、UIImage
や CVPixelBuffer
等のデータを元に作成した場合、これらの Metal 用オブジェクトのメモリ管理は ARC (Auto Reference Count) に則ります。(※ Metal 用オブジェクトが元の画像データが管理しているメモリを参照するということです。Metal フレームワークに用意されている関数を使って Metal 用オブジェクトを生成する場合はこうなるはずです。)
これはつまり以下のようなコードでは、描画パイプラインのコールが完了する下記コードの .commit()
の後に ARC が作動し、Metal 用オブジェクトが解放され nil
となることを意味します。
var textureLoader: MTKTextureLoader?
var options: [MTKTextureLoader.Option : Any]?
var mtlTexture: MTLTexture?
...
let cgImage = ~~~ (CGImageの生成コード)
mtlTexture = try textureLoader?.newTexture(cgImage: cgImage, options: options!)
...
renderEncoder.setFragmentTexture(mtlTexture, index: idx) // MTLRenderCommandEncoder に MTLTexture オブジェクトを渡して描画する.
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder.endEncoding()
commandBuffer.present(currentDrawable)
commandBuffer.commit() // 大抵、描画パイプラインのコールはこれで完結するため、この後でARCが作動する.
画像等のオブジェクトを毎度CPU側で生成してGPUへ渡すことを繰り返すのは処理のオーバーヘッドとなる場合があるかと思います。
この時、例えば MTLTexture
の元となるデータが UIImage
であったとして、これをクラス変数として保持しておき、この変数から MTLTexture
オブジェクトを生成することで、描画ごとに MTLTexture
オブジェクトが描画後に nil
になるのを防ぐことができるようになります。
(言い換えれば MTLTexture
オブジェクトの参照元データである UIImage
のメモリ管理を適切に行う必要があるということです。)
self.cachedUIImg = UIImage(...) // あるクラスの変数としてUIImage型データを保持しておくと、自動でメモリが解放されない状態にできる.
mtlTexture = try? textureLoader!.newTexture(cgImage: self.cachedUIImg!.cgImage!, options: options)
もしくは以下のようにして ARC 管理されたオブジェクトを介さずに初期化を行えば、Metal 用オブジェクトが UIImage
等に左右されて勝手にメモリ解放されることはありません。 もちろんこのオブジェクトに後から UIImage
等の参照を渡すと、メモリ管理は参照先のスキームに則ることになるので注意が要ります。
var texture: MTLTexture?
var device: MTLDevice?
...
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .r8Unorm, width: width, height: height, mipmapped: false)
desc.usage = MTLTextureUsage(rawValue:
MTLTextureUsage.renderTarget.rawValue |
MTLTextureUsage.shaderRead.rawValue |
MTLTextureUsage.shaderWrite.rawValue)
// ARCオブジェクトを介さずに初期化すれば、当該Textureオブジェクトのメモリが勝手に解放されることはない.
// もちろんクラス変数として保持しておく必要はあります.
texture = device?.makeTexture(descriptor: desc)
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におけるGlobal変数
通常、GLSL等の他のShader言語も含め、Global変数は #define PI 3.14159
のように#defineを用いるのが一般的です。
これは、Metalを含むGPU系のプログラムでは、厳密にはGlobal変数を設定することができないためです.
ここでいう厳密とは、実行時に解釈して変数を定義する #define
のような方法を除き、予めメモリにMetalで用いる変数を通常のC++の手法では定義することができないという意味です. (基本的に関数中で定義することが求められる. この理由は、メモリの確保/開放の仕組みがCPUとGPUとで異なるためです。)
ただし、ファイル内 (Swiftで言う "fileprivate" スコープ) での定義を前提として、.metalファイル内限定で、以下のようにコンパイル時に定義される変数を設定することができます.
#define PI 3.14195f // Shaderで用いられる一般的な定義方法.
const float PI__ = 3.14195; // 通常のC++の手法、これはMetalではコンパイルエラー.
constant float PI_ = 3.14195; // constantはMetal用の修飾子.
なお、この constant
による定義ですが、行列についてはコンパイルできません.
constant float2x2 m = float2x2(0); // コンパイルエラー.
bridging-headerを用いる
"bridging-header" は、SwiftコードとObjective-Cコードをバインドするための特殊なヘッダーファイルです。
Metalへパラメータ(時間や画面の解像度など)を渡すコードはSwiftで書く方が便利かと思いますが、このようなパラメータをC++で書かれるMetalファイルへ受け渡す際にはbridging-headerが必要になります。
しかしながらbridging-headerは純粋なObjective-Cのコードしかバインドできず、Metalのコードを直接バインドすることはできません。
そこで筆者は通常、以下のようにヘッダーとMetalファイルを構成するようにしています。
- "ShaderTypes.h" はbridging-headerでバインドされるObjective-C(もしくはObjective-C++)であるという制約があるので、ここではMetalが使えません。ここではMetalで使うための構造体を純粋なC++のみで定義します。(下記コードは一例です。このように定義すれば、これらをSwiftおよびMetalの両方で使用可能になります。)
typedef enum VertexAttributes {
kVertexAttributePosition = 0,
kVertexAttributeTexcoord = 1,
kVertexAttributeNormal = 2
} VertexAttributes;
typedef struct {
matrix_float4x4 projectionMatrix;
matrix_float4x4 viewMatrix;
float iTime;
vector_float2 iResolution;
} SharedUniforms;
- "〇〇〇.h" は、Metalのコードを記述するためのヘッダーファイルとして活用します。したがって
using namespace metal;
を宣言し、純粋なC++及びMetal用の(Objective-Cではないファイル)として用います。 - "〇〇〇.metal" は、〇〇〇.h と ShaderTypes.h の双方をインクルードします。これによって、Swiftにバインドされた型をMetal内で用いることができるようになります。
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, [])
}
注意点
writeFrame()関数の入力である MTLTexure は、上記の通りMTKViewインスタンスのattributeである currentDrawable.texture
(MTLTexture型) を入力しています。
通常MTLTexture型は、let ciImg = CIImage(mtlTexture: tex, options: nil)
のような形でCIImageを作ることができます。しかしながら、ここで用いる currentDrawable.texture
はこの CIImage を作る方法では変換が正しく機能しません(理由は調査中)。
上記のように texture.getBytes
を介することで UIImage へと正しく変換できるようになります。
参考
Metalの描画の仕組みについて、筆者のこちらの記事で図解してみましたのでご参考にしていただけますと幸いです。
終わりに
改善方法やご意見などあれば、どしどしコメント下さい!