12
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-08-11

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

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オブジェクトのメモリ管理

MTLBufferMTLTexture などのMetalにデータを渡すオブジェクトを、UIImageCVPixelBuffer 等のデータを元に作成した場合、これらの 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ファイルを構成するようにしています。

Header.png
  • "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の描画の仕組みについて、筆者のこちらの記事で図解してみましたのでご参考にしていただけますと幸いです。

終わりに

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

12
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?