現在のプロジェクトでMetalに触れる必要があったのでざっくり調べてみました。
以下の記事を最初に読んでおくと色々と理解しやすくてオススメです。
Metalのスピードアップの要員のひとつはCPU/GPUのメモリ共有
上記の記事のざっくり概要だけを書くと、Metalのスピードアップの秘訣は、CPU/GPU間でのメモリ共有です。(それだけじゃないですが)
そのため、OpenGLではいちいちデータのやりとりを小難しくセットアップする必要がありましたが、Metalではそれがありません。
しかしMetalはiOS端末の、さらに限られたチップが搭載された端末に限定している技術です。
(逆に言うと、だからこそ実現できている技術です)
細かい話をすると、本来GPUとCPUは物理的に離れています。
(自作PCを作った経験がある人であればGPUが離れているのはすぐ分かると思います)
CPUの処理スピード的にこの距離が致命的なのです。
それがA7チップ以降、(というかiPhoneの構造上)CPUとGPUはほぼ同居しており、前述のようにメモリの共有ができるようになった、というわけです。
なのでシミュレータでは動かないんですね。(PCだと当然GPUは離れてる)
Metalのセットアップ
主にこちらの記事を参考にしました。
Metalを利用するには最低限、以下のものを準備します。
- 「MTLDevice」の生成
- 「CAMetalLayer」の生成
- 「Vertex Buffer」の生成
- 「Vertex Shader」の生成
- 「Fragment Shader」の生成
- 「Render Pipeline」の生成
- 「Command Queue」の生成
- 「Command Buffer」の生成
- 「Render Pass Descriptor」の生成
- 「Render Command Encoder」の生成
MTLDevice
の生成
MTLDevice
はGPUにダイレクトにコネクションを張るものと考えることができます。
OpenGLでいうところのglコンテキストに似ています。
テクスチャの生成、バッファの生成などGPUへの命令はこのMTLDevice
を通して行います。
// MTLDeivceの生成
id <MTLDevice> device = MTLCreateSystemDefaultDevice();
CAMetalLayer
の生成
CAMetalLayer
はMetal.frameworkではなく、名前からも分かる通りCore Animation
で定義されています。
なのでQuartzCore.frameworkを読み込む必要があります。
役割はMetalのレンダリングを行うレイヤーです。
import QuartzCore;
// CAMetalLayerの生成
CAMetalLayer *metalLayer = [CAMetalLayer layer];
metalLayer.device = device;
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
metalLayer.framebufferOnly = YES;
metalLayer.frame = self.view.layer.frame;
[self.view.layer addSublayer:metalLayer];
Vertex Buffer
の生成
OpenGLではVBO
ですね。
// 頂点情報
CGFloat vertexData[] = {
0.0, 1.0, 0.0,
-1.0, -1.0, 0.0,
1.0, -1.0, 0.0,
};
NSInteger dataSize = sizeof(vertexData);
id <MTLBuffer> vertexBuffer = [device newBufferWithBytes:vertexData length:dataSize options:MTLResourceOptionCPUCacheModeDefault];
MTLDevice
でも触れた通り、deviceを通してバッファオブジェクトを生成しています。(newBufferWithBytes:length:options:
メソッドがそれです)
また、Metalの特徴として大部分のオブジェクトがProtocolで実装されている点です。
このへんもスピードアップのためなのでしょうか。
なのでいたるところでid型
が登場します。
Vertex Shader
の生成
さて、グラフィックAPIと言えばシェーダですね。
Metalでも当然シェーダを書きます。なお、シェーダは.metal
ファイルになります。
vertex float4 basic_vertex(
const device packed_float3 *vertex_array [[buffer(0)]],
unsigned int vid [[vertex_id]]) {
return float4(vertex_array[vid], 1.0);
}
シェーダの決まり事
すべてのバーテックスシェーダはvertex
キーワードで始める必要があります。
そして関数は、最終的なpositionを返さないとなりません。
このあたりはCg/HLSLと似たような感じですね。
引数
最初の引数はpacked_float3
を指定します。これは頂点位置情報など(3つの要素を持つ配列)の配列になります。
[[ ]]
syntax
[[ ]]
で囲まれた部分は追加情報の属性として使われます。
(例えばリソース位置やシェーダの入力、ビルトイン変数など)
ここで指定されている[[ buffer(0) ]]
はMetalのコードからシェーダに送られたデータのうち、最初のデータを指していることを伝えています。
2つ目の引数はvertex_id
属性がついたものです。
これはシェーダに渡された頂点配列のインデックスを示すものです。
(なのでvertex_array[vid]
でアクセスしている)
構造体でデータを得る
なお、Cg/HLSLと同じように構造体を宣言して、構造体の状態でバッファからデータを受け取り、また返すことも可能です。以下のようにします。
// 構造体の宣言
struct VertexIn {
packed_float3 position;
packed_float4 color;
};
struct VertexOut {
float4 position [[position]];
float4 color;
};
vertex VertexOut vertex_shader_function(const device VertexIn *vertex_array [[buffer(0)]], unsigned int vid [[vertex_id]]) {
// do something.
}
fragment half4 fragment_shader_function(VertexOut input [[stage_in]]) {
// do something.
}
Uniform変数を受け取る
上記サンプルでは使用していませんが、Uniform変数も受け取ります。
受け取る場合は以下のようにします。
vertex float4 vertex_function(const device packed_float3 *vertex_array [[buffer(0)]],
const device Uniforms &uniforms [[buffer(1)]],
unsigned int vid [[vertex_id]]) {
// ...
}
シェーダへデータを送る
シェーダにデータを送るには以下のようにMTLRenderCommandEncoder
を使います。
これまたid型
ですね。
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
[renderEncoder setVertexBuffer:vertexBuffer offset:0 atIndex:0];
このatIndex:0
が、[[buffer(0)]]
で読み取れるデータです。
また、構造体で受けるようにした場合は頂点情報の数は構造体で受け取る数と同じだけ定義しないとなりません。
例えば前述の例だとpacked_float3
とpacked_float4
を定義しているので、1頂点あたり7個の数値が必要です。
CGFloat data[] = {
1, 2, 3, 4, 5, 6, 7, // 7個でひとつの頂点分の情報になる
8, 9, 10, 11, 12, 13, 14,
...
};
Fragment Shader
の生成
フラグメントシェーダは以下のような形になります。
fragment half4 basic_fragment() {
return half4(1.0);
}
シェーダの決まり事
すべてのフラグメントシェーダはfragment
キーワードで始める必要があります。
そしてすべての関数は最終的な色(例ではhalf4
)を返さないとなりません。
Render Pipeline
の生成
レンダーパイプラインを生成します。
Metalの利点としてシェーダがプリコンパイルされる点があります。
また、セットアップが終わった段階でレンダーパイプライン構成がコンパイルされるため、非常に効率的に実行することができます。
(このあたりが、OpenGLより10倍早い、みたいな最適化のいったんなんでしょうね)
id <MTLRenderPipelineState> pipelineState;
id <MTLLibrary> defaultLibrary = [device newDefaultLibrary];
id <MTLFunction> fragmentProgram = [defaultLibrary newFunctionWithName:@"basic_fragment"];
id <MTLFunction> vertexProgram = [defaultLibrary newFunctionWithName:@"basic_vertex"];
MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.vertexFunction = vertexProgram;
pipelineStateDescriptor.fragmentFunction = fragmentProgram;
[pipelineStateDescriptor.colorAttachments objectAtIndexedSubscript:0].pixelFormat = MTLPixelFormatBGRA8Unorm;
NSError *pipelineError = nil;
pipelineState = [device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor error:&pipelineError];
if (!pipelineState) {
NSLog(@"Failed to create pipeline state, error %@", pipelineError);
}
[device newDefaultLibrary]
で、シェーダライブラリを操作するオブジェクトを得ます。
ちなみに、Metalはコンパイル時に.metal
ファイルを調査してすべてをライブラリとしてまとめるようです。そのため、[defaultLibrary newFunctionWithName:@"basic_fragment"]
のようにして関数名だけで該当のシェーダコードを取得することができる、というわけです。
そしてMTLRenderPipelineDescriptor
オブジェクトをインスタンス化し、適切に設定を行います。(これが前述したパイプラインの設定)
(雰囲気的にはOpenGLでいうところのglProgramに近いですかね)
文字列からシェーダを生成する
.metal
形式のファイルにシェーダを記述するとコンパイル時に自動的にライブラリに含めてくれますが、中には文字列からシェーダを生成したい、という場合があると思います。
そうした場合はnewLibraryWithSource:options:error:
メソッドを使い、引数にシェーダコードを文字列として渡してやることで同期的にコンパイルしてくれます。
ハマりポイント
地味にハマったのが、渡すソースコードの記述法。
\n
を使って明示的に改行コードを入れてあげないと、エラーがでないのにシェーダの関数が参照できない、という問題がありました。
なので、文字列から生成する場合は要注意です。
Command Queue
の生成
id <MTLCommandQueue> commandQueue = [device newCommandQueue];
コマンドキューは、GPUに対する命令のオーダーリストです。
(おそらくキューの名の通り、コマンドがひとつずつ取り出されて実行される?)
コマンドキューを利用する理由は、CPUとGPUでは動作周期が異なりそれぞれが非同期に実行されるため、安全にデータをやりとりするための仕組みとしてキューがあります。
後述しますが、このキューに「コマンドバッファ」と呼ばれるオブジェクトをキューに追加していくことでレンダリングを実現します。
多分ですがコマンドバッファが一回のDraw Callっぽいです。
なのでコマンドバッファには処理が終了したことを通知するハンドラを設定することができます。
もしCPU側でデータを変更したい場合は、このコールバックを待ってから処理をすることで安全にデータを変更できる、というわけです。
三角形のレンダリング
さて、これでセットアップが終わったので次はレンダリングです。
主に以下のステップになります。
- CADisplayLinkの生成
- Render Pass Descriptorの生成
- Command Bufferの生成
- Render Command Encoderの生成
- Command Bufferのコミット
※ ... 本来、CADisplayLink
はレンダリングには直接は関係ありませんが、3Dはレンダリングを常に行うが普通なのでそのセットアップも含めています。
CADisplayLinkの生成
CADisplayLinkの生成は特に新しいことはありません。
CADisplayLink *timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(gameloop)];
[timer addToRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode];
/////////////////////////////////////////////////////////////////////////////
- (void)gameloop
{
@autoreleasepool {
[self render];
}
}
render
メソッドの中で、Metalでのレンダリング処理を実行すれば随時画面が更新されるようになります。
Render Pass Descriptorの生成
レンダリング用のdescriptorを生成します。
// Render Pass Descriptorの生成。
MTLRenderPassDescriptor *renderPassDescriptor = [[MTLRenderPassDescriptor alloc] init];
renderPassDescriptor.colorAttachments[0].texture = texture;
renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0, 104.0/255.0, 5.0/255.0, 1.0);
renderPassDescriptor.colorAttachments[0].storeAction = MTLStoreActionStore;
生成にはMTLTexture
が必要です。ここに設定したテクスチャオブジェクトにレンダリング処理が行われます。
なのでここに、オフスクリーン用に生成したテクスチャを指定すれば簡単にオフスクリーンレンダリングができます。
Command Bufferの生成
コマンドバッファは、1フレームに実行したいレンダーコマンドのリストのイメージです。
コマンドバッファはコマンドキューオブジェクトから生成します。
// create a new command queue
_commandQueue = [_device newCommandQueue];
/////////////////////////////////////////////////////////////////////////////
id <MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];
ちなみにコマンドバッファは、明示的にコミットを行うまでなにも実行しません。
Render Command Encoderの生成
実際にレンダリングを行うコマンドです。
これ以外にもMTLComputeCommandEncoder
やMTLBlitCommandEncoder
といったレンダリングを行わないコマンドもあります。(MTLComputeCommandEncoder
はフィルターなど、テクスチャに処理を施してテクスチャとして書き出す、みたいなことができる)
// Render Command Encoderの生成
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
if (renderEncoder) {
[renderEncoder setRenderPipelineState:self.pipelineState];
[renderEncoder setVertexBuffer:self.vertexBuffer offset:0 atIndex:0];
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3 instanceCount:1];
[renderEncoder endEncoding];
}
glDrawElements
的な役割。
ちなみに生成は「コマンドバッファ」から行います。
※ ... 「バッファ」と名が付いている通り、いくつかのコマンドをひとまとめにしてキューに入れることができます。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3 instanceCount:1];
で指定したvertexCount
やinstanceCount
が、前述した、構造体でデータを送る際の指標になるものです。
前述の例で言えばvertexCount
は7
になります。
バッファデータの生成についてメモ
OpenGLで言うところのVBO
にあたるバッファの種類について。
OpenGL同様、attribute
やuniform
にあたる変数があります。
attribute
なバッファを生成するには以下のようにします。
// 頂点データを定義する
static const float vertexData[] = {
0.0, -1.0, 0.0,
-1.0, 1.0, 0.0,
1.0, 1.0, 0.0
};
以下のようにしてバッファオブジェクトにします。
id <MTLBuffer> buffer = [device newBufferWithBytes:vertexData length:sizeof(vertexData) options:MTLResourceOptionCPUCacheModelDefault];
次に、uniform
なバッファを生成するには以下のようにします。
static const float uniformData[] {
1.0, 1.0, 1.0, 1.0,
};
以下のようにしてバッファオブジェクトにします。
id <MTLBuffer> uniformBuffer = [device newBufferWithLength:sizeof(uniformData) options:MTLResourceOptionCPUCacheModelDefault];
attribute
との違いはnewBufferWithLength:options:
メソッドを利用する点です。
これはバッファの領域を確保するのみで、データ自体はあとから設定します。
具体的には以下のようにします。
void *buf = [uniformBuffer contents];
memcpy(buf, uniformData, sizeof(uniformData));
コマンドバッファのコミット
[commandBuffer presentDrawable:view.currentDrawable];
[commandBuffer commit];
最後にレンダリングをGPUに行わせるためにコマンドバッファをコミットします。
commit
メソッドはキューに追加し、もし可能であれば即座にコマンドが実行されます。(キューが待ち状態の場合は最後に追加されます)