やりたいこと:
@IBOutlet weak var imageView: MetalImageView!
imageView.image = uiImage // UIImage
imageView.image = mpsImage // MPSImage
imageView.image = mtlTexture // MTLTexture
imageView.image = ciTexture // CIImage
こんな感じでUIImageViewライクに手軽に、かつなるべくCPUとGPUを行ったり来たりせずに(余計なコンバートをせずに)高速描画したい。
##MTKView
Metalで描画できるUIViewサブクラスの MTKView
というものがあるのですが、これが・・・ただ単にテクスチャを描画したいだけだとしてもかなりの手順が必要になります。
コマンドキュー、コマンドバッファ、コマンドエンコーダ、パイプライン・・・UIKitやCore Graphicsでは意識することのなかった概念がたくさん登場します。OpenGLに慣れ親しんだ人からするとわかりやすいらしいですが、GPUプログラミング初心者である自分からすると、Appleのサンプル等はやりたいことに対して複雑すぎるように感じてしまいます。
というわけでここをラップするMTKViewのサブクラスをつくりつつ、Metalでテクスチャを描画するための最小実装を探っていきたいと思います。
class MetalImageView: MTKView, MTKViewDelegate
以下でその実装の要点を書きます。
※ 2018.1.27追記
本記事を書いたころはまだMetalの理解が浅く(今でもまだまだ勉強中ですが)、以下の実装や解説は、結構見当違いなことをやってたり言ってたりします。
たとえば単にMTKView
に画像を描画したいのであればブリットコマンドエンコーダを利用する方がシンプルです。こちらの動画あるいはスライドあるいは書籍をご参照ください。
##UIImage を MTLTexture に変換する
MetalではMTLTexture
というプロトコルに適合したオブジェクトをテクスチャとして扱います。
UIImageからMTLTextureを生成する、もしくはアセットから直接生成する実装については以下の記事に書いたので、適宜ご参照ください。
##シェーダを読み込みパイプラインステートを作成する
Metalシェーダのファイル(xxxx.metal)を読み込んで MTLLibrary
を生成し、そこから使用する関数を取り出して MTLFunction
を生成し、
guard let library = device.makeDefaultLibrary() else {fatalError()}
guard let function = library.makeFunction(name: "kernel_passthrough") else {fatalError()}
その関数を引数に渡して、パイプラインステート(ここではコンピュートシェーダを使うのでMTLComputePipelineState
)を生成します。
pipeline = try! device.makeComputePipelineState(function: function)
「パイプラインステート」というのがGPUプログラミング初心者としてはあまりピンと来ないのですが、同クラスのリファレンスを見ると、
The MTLComputePipelineState protocol defines the interface for a lightweight object used to encode a reference to a compiled compute program.
とあり、コンパイル済みのコンピュートシェーダ関数をコマンドエンコーダで取り扱うためのもののようです。
ちなみにシェーダはこんな感じで、入力テクスチャをそのまま出力に渡しています。
#include <metal_stdlib>
using namespace metal;
kernel void kernel_passthrough(texture2d<float, access::read> inTexture [[texture(0)]],
texture2d<float, access::write> outTexture [[texture(1)]],
uint2 gid [[thread_position_in_grid]])
{
float4 inColor = inTexture.read(gid);
outTexture.write(inColor, gid);
}
参考: http://stackoverflow.com/questions/33605241/display-jpeg-image-using-
##その他初期化時にやること
###コマンドキューを生成
MTLCommandQueueについてはこちらの記事の説明が完結でわかりやすいので引用させていただきます。
レンダリング(またはコンピューティング)コマンドをGPUに流すためのキュー。
GPUのタスクが空き次第プッシュされた順にコマンドが実行されます。
ここにコマンドバッファをプッシュしていくので、最初につくっておきます。
commandQueue = device.makeCommandQueue()
###MTKViewのセットアップ
ちゃんと実装しようとするともっと色々必要そうですが、とりあえず今回の方法で最小限セットしないといけないのは以下の2つ。
framebufferOnly = false
delegate = self
framebufferOnly
はデフォルトでは true
で、そのままだとコンピュートシェーダが使えない的な実行時エラーになります。
今回の要件では描画を同じクラス内で処理したいので delegate
には自身をセットします。
##描画するテクスチャとMTKViewのピクセルフォーマットを合わせる
描画するMTLTexture
がセットされたら(MTKTextureLoaderを用いてロードしたら)、自身(MTKView)のcolorPixelFormat
を合わせます。
colorPixelFormat = texture.pixelFormat
これをやらないと、描画されても変な色になります。
##MTKViewDelegateの実装
2つのMTKViewDelegate
プロトコルのメソッドを実装する必要があります。
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize)
func draw(in view: MTKView)
###drawInMTKView:
でやること
ビューに描画を行う際に呼ばれるメソッドです。
Called on the delegate when it is asked to render into the view
ここでやるべきことをシンプルに言うと、
「コマンドを生成してコマンドキューにプッシュする」
となります。
コマンドキューについては上述した通りですが、「コマンドの生成」についてはコマンドバッファ(MTLCommandBuffer
), コマンドエンコーダ(MTLRenderCommandEncoder
, MTLComputeCommandEncoder
)が絡んできます。これらについて、また下記サイトの説明をお借りします。
[MTLCommandBuffer]
MTLCommandQueueにプッシュするコマンド本体。
コマンドは主にGPUで実行されるシェーダプログラムやテクスチャ・頂点データのポインタ、その他のパラメータのポインタなどの情報で構成されます。
レンダリング用のMTLCommandBufferのオブジェクトを作成するには次のMTLRenderCommandEncoderを使用します。
[MTLRenderCommandEncoder] 1
レンダリングコマンドを作成するための役割を持つクラスです。
MTLBufferやMTLTextureのオブジェクトのポインタや、以下で紹介するMTLRenderPipelineStateのオブジェクトを元にコマンドを生成します。
以下のように実装しました。
func draw(in view: MTKView) {
guard let drawable = view.currentDrawable else { return }
guard let texture = texture else { return }
let commandBuffer = commandQueue.makeCommandBuffer()
let encoder: MTLComputeCommandEncoder = commandBuffer.makeComputeCommandEncoder()
encoder.setComputePipelineState(pipeline)
encoder.setTexture(texture, at: 0)
encoder.setTexture(drawable.texture, at: 1)
encoder.dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
###Drawable
上記実装で、
guard let drawable = view.currentDrawable else {
return
}
と、MTKView オブジェクトから currentDrawable
なるものを取得し、
encoder.setTexture(drawable.texture, at: 1)
その texture
をシェーダの第2引数(つまり出力側)に渡しています。
で、最後に
commandBuffer.present(drawable)
commandBuffer.commit()
コマンドバッファに渡してコミット。(→コマンドキューにプッシュされる)
この currentDrawable
の型は CAMetalDrawable
で、ヘッダにはこう説明されています。
CAMetalDrawable represents a displayable buffer that vends an object that conforms to the MTLTexture protocol that may be used to create a render target for Metal.
###スレッドグループ
もうひとつ、上記実装で
encoder.dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
ということをやっています。引数に渡している2つの値はどちらもMTLSize
型で、GPUで並列に実行するスレッドの数を指定しています。
それぞれの引数について、リファレンスでは以下のように書かれています。
threadgroupsPerGrid
The number of threadgroups for the grid, in each dimension.
threadsPerThreadgroup
The number of threads in one threadgroup, in each dimension.
前者が(グリッドあたりの)スレッドグループの数、後者が1つのスレッドグループにおけるスレッド数、になります。
正直どういう値をセットしてよいかわからないので、ここはAppleのMetalImageProcessingサンプルを参考に設定しました。(コンピュートシェーダで画像を取り扱っているという点で、状況は似通っているのかなと)
private let threadsPerThreadgroup = MTLSize(width: 16, height: 16, depth: 1)
threadgroupsPerGrid = MTLSize(width: (texture.width + threadsPerThreadgroup.width - 1) / threadsPerThreadgroup.width,
height: (texture.height + threadsPerThreadgroup.height - 1) / threadsPerThreadgroup.height,
depth: 1)
###余談:処理を書かないとどうなる?
何が必須で、何が必須じゃないかを探るため、まずはエンコーダをコメントアウトしてみます。
let commandBuffer = commandQueue.makeCommandBuffer()
// let encoder = commandBuffer.makeComputeCommandEncoder()
// encoder.setComputePipelineState(pipeline)
// encoder.setTexture(texture, at: 0)
// encoder.setTexture(drawable.texture, at: 1)
// encoder.dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
// encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
→ 実行時エラーにはならないが、drawableに何も書き込まれていないので、真っ黒に描画される
コマンドバッファもコメントアウトしてみます。
// let commandBuffer = commandQueue.makeCommandBuffer()
// let encoder = commandBuffer.makeComputeCommandEncoder()
// encoder.setComputePipelineState(pipeline)
// encoder.setTexture(texture, at: 0)
// encoder.setTexture(drawable.texture, at: 1)
// encoder.dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
// encoder.endEncoding()
// commandBuffer.present(drawable)
// commandBuffer.commit()
→ 真っ白に描画される。描画命令がないので、そもそも描画が行われず、下のビューが見えている、と解釈しました。
##出来上がり
以上で、
imageView.image = UIImage(named: "filename")
とやるだけでMetalで画像を描画するクラスが出来ました。
が、contentMode
に対応してないし、2x/3xといったRetinaスケールも対応してないので、実際のところUIImageViewの代替としてはまだまだ完成度が程遠い超原始的パージョンです。
最低限の対応(contentMode/Retina)ができたらGitHubにアップしたいと思います。
-
ここではコンピュートシェーダを使ってるので、コマンドエンコーダは
MTLComputeEncoder
になります ↩