LoginSignup
138
122

More than 5 years have passed since last update.

iOSの新グラフィックAPI - Metal入門してみる

Last updated at Posted at 2015-04-21

現在のプロジェクトでMetalに触れる必要があったのでざっくり調べてみました。


以下の記事を最初に読んでおくと色々と理解しやすくてオススメです。

Metalのスピードアップの要員のひとつはCPU/GPUのメモリ共有

上記の記事のざっくり概要だけを書くと、Metalのスピードアップの秘訣は、CPU/GPU間でのメモリ共有です。(それだけじゃないですが)
そのため、OpenGLではいちいちデータのやりとりを小難しくセットアップする必要がありましたが、Metalではそれがありません。

しかしMetalはiOS端末の、さらに限られたチップが搭載された端末に限定している技術です。
(逆に言うと、だからこそ実現できている技術です)

細かい話をすると、本来GPUとCPUは物理的に離れています。
(自作PCを作った経験がある人であればGPUが離れているのはすぐ分かると思います)

CPUの処理スピード的にこの距離が致命的なのです。

それがA7チップ以降、(というかiPhoneの構造上)CPUとGPUはほぼ同居しており、前述のようにメモリの共有ができるようになった、というわけです。
なのでシミュレータでは動かないんですね。(PCだと当然GPUは離れてる)

Metalのセットアップ

主にこちらの記事を参考にしました。

Metalを利用するには最低限、以下のものを準備します。

  1. 「MTLDevice」の生成
  2. 「CAMetalLayer」の生成
  3. 「Vertex Buffer」の生成
  4. 「Vertex Shader」の生成
  5. 「Fragment Shader」の生成
  6. 「Render Pipeline」の生成
  7. 「Command Queue」の生成
  8. 「Command Buffer」の生成
  9. 「Render Pass Descriptor」の生成
  10. 「Render Command Encoder」の生成

MTLDeviceの生成

MTLDeviceはGPUにダイレクトにコネクションを張るものと考えることができます。
OpenGLでいうところのglコンテキストに似ています。
テクスチャの生成、バッファの生成などGPUへの命令はこのMTLDeviceを通して行います。

create-MTLDevice
// MTLDeivceの生成
id <MTLDevice> device = MTLCreateSystemDefaultDevice();

CAMetalLayerの生成

CAMetalLayerはMetal.frameworkではなく、名前からも分かる通りCore Animationで定義されています。
なのでQuartzCore.frameworkを読み込む必要があります。
役割はMetalのレンダリングを行うレイヤーです。

create-CAMetalLayer
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ですね。

create-Vertex_Buffer
// 頂点情報
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ファイルになります。

create-Vertex_shader
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_float3packed_float4を定義しているので、1頂点あたり7個の数値が必要です。

CGFloat data[] = {
    1, 2,  3,  4,  5,  6,  7, // 7個でひとつの頂点分の情報になる
    8, 9, 10, 11, 12, 13, 14,
    ...
};

Fragment Shaderの生成

フラグメントシェーダは以下のような形になります。

create-Fragment_shader
fragment half4 basic_fragment() {
    return half4(1.0);
}

シェーダの決まり事

すべてのフラグメントシェーダはfragmentキーワードで始める必要があります。
そしてすべての関数は最終的な色(例ではhalf4)を返さないとなりません。


Render Pipelineの生成

レンダーパイプラインを生成します。

Metalの利点としてシェーダがプリコンパイルされる点があります。
また、セットアップが終わった段階でレンダーパイプライン構成がコンパイルされるため、非常に効率的に実行することができます。
(このあたりが、OpenGLより10倍早い、みたいな最適化のいったんなんでしょうね)

Create-Render_pipeline
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の生成

Create-Command_queue
id <MTLCommandQueue> commandQueue = [device newCommandQueue];

コマンドキューは、GPUに対する命令のオーダーリストです。
(おそらくキューの名の通り、コマンドがひとつずつ取り出されて実行される?)

コマンドキューを利用する理由は、CPUとGPUでは動作周期が異なりそれぞれが非同期に実行されるため、安全にデータをやりとりするための仕組みとしてキューがあります。

後述しますが、このキューに「コマンドバッファ」と呼ばれるオブジェクトをキューに追加していくことでレンダリングを実現します。

多分ですがコマンドバッファが一回のDraw Callっぽいです。
なのでコマンドバッファには処理が終了したことを通知するハンドラを設定することができます。
もしCPU側でデータを変更したい場合は、このコールバックを待ってから処理をすることで安全にデータを変更できる、というわけです。


三角形のレンダリング

さて、これでセットアップが終わったので次はレンダリングです。
主に以下のステップになります。

  1. CADisplayLinkの生成
  2. Render Pass Descriptorの生成
  3. Command Bufferの生成
  4. Render Command Encoderの生成
  5. 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の生成

実際にレンダリングを行うコマンドです。
これ以外にもMTLComputeCommandEncoderMTLBlitCommandEncoderといったレンダリングを行わないコマンドもあります。(MTLComputeCommandEncoderはフィルターなど、テクスチャに処理を施してテクスチャとして書き出す、みたいなことができる)

Create-Render_command_encoder
// 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];で指定したvertexCountinstanceCountが、前述した、構造体でデータを送る際の指標になるものです。
前述の例で言えばvertexCount7になります。

バッファデータの生成についてメモ

OpenGLで言うところのVBOにあたるバッファの種類について。
OpenGL同様、attributeuniformにあたる変数があります。

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メソッドはキューに追加し、もし可能であれば即座にコマンドが実行されます。(キューが待ち状態の場合は最後に追加されます)

参考にした記事

138
122
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
138
122