T.TAO
Back to Blog
/17 min read/Graphics Engine

Metal #2 Rendering Pipeline

#Graphics Engine
Metal #2 Rendering Pipeline

この記事では、主に Metal における rendering pipeline に関する知識点について説明する。

ハードウェアの基礎

まず、GPU と CPU の違いを知る必要がある。

  • GPU (Graphics Processing Unit): グラフィックス処理ユニット、あるいはグラフィックスプロセッサ。このハードウェアの役割は大量のデータを処理することである。高度に並列化された構造を採用しているため、画像や動画のような大規模なデータを高速に処理できる。
  • CPU (Central Processing Unit): 中央処理ユニット、あるいは中央プロセッサ。このハードウェアの役割は順序付けられたデータを高速に処理することであり、データの処理は一つずつ順番に行われる。

CPU は命令を GPU に伝達する。Metal の戦略は、CPU 上で command buffer を使用して複数の CPU 命令を保存し、ブロックを防ぐために、GPU が現在のタスクを完了するのを待つのではなく、CPU が次のフレームに向けて継続的に命令を発行するというものである。

rendering pipeline

トップレベルの視点から見ると、各種 API の rendering pipeline には実はそれほど大きな違いはない。Metal の公式ドキュメントでは、Metal の rendering pipeline を application ステージ - vertex ステージ - rasterization ステージ - fragment ステージ - pixel ステージとしてまとめている。ローレベルの視点から見ると、上記の各ステップを実現するためには、プログラムが使用する抽象概念に対して具体的な制御を行う必要がある。

ゼロからプロジェクトを作成し、全体のフローを実行することで、Metal における rendering pipeline の実装を理解することができる。Multiplatform の App を新規作成する。

初期化

MetalView

SwiftUI では、import MetalKit を通じて MTKView を取得できる。MTKView を取得して使用するには、MTKViewUIViewRepresentable (iOS) または NSViewRepresentable (macOS) でラップする必要がある。例えば、

上記のコードの MetalViewRepresentableViewRepresentable プロトコルに準拠している必要がある。macOS では makeNSView()updateNSView() の2つの関数を実装する必要があり、iOS では makeUIView()updateUIView() の2つの関数を実装する必要がある。具体的な手順は本記事の範囲外であるため、以下に参考コードを示す。

そして ContentView.swift で最もシンプルなウィンドウを作成する。

Renderer クラス

他の API では、何らかの方法で1フレーム内のライフサイクル、あるいは game loop を手動で実装する必要がある。Metal では、Apple が MetalKit を提供しており、その内部には game loop の実装方法を簡略化するための構造が用意されている。そのため、Metal では MetalKit と、自分で記述した(かつ MTKViewDelegate プロトコルに準拠した) Renderer クラスを組み合わせて rendering 呼び出しを実装する。

MTKViewDelegateMTKView に関連するコールバックメソッドを定義している。このプロトコルを通じて、MTKView のイベントを監視し応答することができる。主に以下の2つのメソッドがある:

  1. mtkView (_: drawableSizeWillChange: ): このメソッドは MTKView の drawable サイズが変更されたときに呼び出される。簡単に言えば、ウィンドウサイズが変更されたときである。
  2. draw (in: ): このメソッドは毎フレーム呼び出される。通常、このメソッド内で Metal API を呼び出して rendering を行う。

つまり、以下のような Renderer が必要になる。

Renderer を NSObject から継承させるのは、主に Apple の歴史的な遺物によるものである。多くの UIKit / Cocoa framework のコア機能は依然として Objective-C ベースで実装されているため、Renderer を NSObject から継承させ、extension を通じて MTKViewDelegate に準拠させる。

次に、MetalView@State 変数を追加し、自身の Renderer が誰であるかを認識させる。ウィンドウの初期化時に、Renderer 内の metalView を現在の metalView に設定する。

1回だけ設定する変数

初期化ステップ (Initialization) の目的は、デバイス、状態、命令、buffer などの参照を取得できるようにすることである。他の API と比較した Metal の優れた特性の一つは、毎フレームこれらの処理を行うのではなく、初期化ステップで多くの変数を事前に設定できることである。

その中で、1回だけ設定すればよい変数(かつシングルトンとして扱うべきもの)がいくつかある:

  • MTLDevice: GPU デバイスの参照。
  • MTLCommandQueue: CPU が入力する command buffer のキュー。
  • MTLLibrary: shader コードを含む関数ライブラリ。

また、要件に応じて複数設定できる変数もある:

  • MTLBuffer: buffer。具体的には、vertex の情報などが格納される。いわゆる vertex データをこのキャリアを通じて GPU に伝達する。
  • MTLRenderPipelineState: rendering state の具体的な設定。例えば、どの shader を使用するか、depth 設定、カラー設定、vertex データの読み取りルールなど。

これらはすべて Renderer クラスが担当すべきである。

まず、1回だけ設定すればよい3つの変数を宣言する。ここでは、それらをすべて暗黙的アンラップオプショナル (implicitly unwrapped optionals)、つまり ! として設定している。この役割は、変数が nil である可能性を示しつつ、使用時には自動的にアンラップされるため、毎回手動でアンラップ(?! を使用して値にアクセス)する必要がないことである。もしこれを通常のオプショナル型 (?) として定義した場合、使用時に明示的にアンラップする必要がある。例えば:

または

SwiftRenderer.device?.someMethod()

このようにすると少し面倒である。! として定義すれば、直接使用できる。

SwiftRenderer.device.someMethod()

同様に、rendering 過程で変化する可能性のあるオブジェクトに対しても、事前に変数を設定しておくことができる。

以下では、init 関数内で、スーパークラスの init を呼び出す前に、これらの変数の値を設定しておく。

最後に、画面をクリアするための clear color を設定しておくこともできる。

適当な mesh を設定する

実際のプロジェクトでは、手動で宣言して mesh を作成することはほとんどなく、一般的には何らかのファイルを読み込む。しかし、本記事の議論を簡単にするため、ここでは適当なボックスを配置しておく。mesh を宣言した後は、それを GPU に pass する必要があることに注意する。

rendering pipeline state の設定

rendering pipeline state は通常、pipeline state object (PSO) を通じて記述される。状態には、その時点でアクティブな vertex shader と fragment shader、その時点の vertex descriptor、pixel format などが含まれる。

ここでは、すでに vert と frag の2つの shader 関数が存在すると仮定し、以下の設定を通じて PSO を作成できる。

作成が完了したら、pipelineState をこの PSO が記述する状態に設定する。上記のコードは super.init() の前に行われ、ここで設定しているのは rendering pipeline の初期状態である。

上記で設定したこれらの状態を含め、rendering pipeline でよく使われる状態には以下のものがある:

グラフィックス関数と関連データの指定

  • vertex 関数 (vertexFunction)
  • fragment 関数 (fragmentFunction)
  • 最上位 vertex shader 関数の最大関数呼び出し depth (maxVertexCallStackDepth)
  • 最上位 fragment shader 関数の最大関数呼び出し depth (maxFragmentCallStackDepth)

rendering pipeline state の指定

  • カラーデータの attachment 配列 (colorAttachment)
  • depth データの pixel format attachment (depthAttachmentPixelFormat)
  • stencil データの pixel format attachment (stencilAttachmentPixelFormat)
  • デフォルト状態へのリセット (reset)

buffer layout と取得動作の指定

  • vertex descriptor (vertexDescriptor)

他にも設定や取得が可能なデータが多数あるため、詳細は Apple Metal のドキュメントを参照してほしい。

vertex descriptor

vertex データは buffer の形式で GPU に転送されるため、最終的には単なるバイトの羅列になる。GPU はこの大量のバイトをどのように解釈すべきかを知る必要があり、そうでなければこれらのデータは意味を持たない。Metal は vertex descriptor (Vertex Descriptor) を使用してこのタスクを完了する。vertex descriptor は、MTLBuffer に配置したデータのデータ構造を GPU に理解させるためのものである。

まず、vertex に関連するいくつかの用語を理解しておく:

  • attribute (attributes): 例えば位置、法線、vertex 座標など。1つの vertex が複数の attribute を持つことがある。そのため、GPU に渡すデータは以下のような
Swiftv1 = [position_v1, normal_v1, uv_v1],
v2 = [position_v2, normal_v2, uv_v2],
//...

buffer = [v1, v2, ...]

形式になる可能性がある。

  • layout (layouts): stride (stride) のような、vertex に関連するいくつかのデータを指定する。

Metal の vertex descriptor で使用される構文は以下の通りである。

Swiftlet vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float3
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0

vertexDescriptor.layouts[0].stride = 
	MemoryLayout<SIMD3<Float>>.stride

pipeline state descriptor

rendering

draw は毎フレーム実行され、この関数内で GPU に送信する命令を設定する。振り返ると、命令を送信するには、命令を格納するキャリアである commandBuffer、命令キャリアのキューである commandQueue(初期化時にすでに設定されているはずである)が必要である。

rendering は描画命令、つまり draw command から始まる。この命令では、vertex の数と描画する primitive のタイプを伝える必要がある。例えば、0番目の vertex から開始し、三角形として3つの vertex を描画する rendering 命令は以下のようになる。

Swift[renderEncoder drawPrimitives: MTLPrimitiveTypeTriangle
			   vertexStart:0
			   vertexCount:3];

vertex ステージは各 vertex にデータを提供する。十分な数の vertex が処理されると、rendering pipeline は primitive の rasterization を開始し、rendering ターゲット上のどの pixel が primitive の「内側」にあるかを決定する。その後、fragment ステージにおいて、rendering pipeline はそれらの pixel に書き込む具体的なカラー値を決定する。

rendering pipeline がデータを処理する方法

概要

Vertex Function (vertex shader) が各 vertex の vertex データを生成し、Fragment Function (fragment shader) が各 fragment に fragment データを提供することはすでに知っている。しかし、これらのデータの内容はすべてカスタマイズ可能であり、それこそがこれら2つの shader が存在する理由である。

Metal のドキュメントでは、通常、どのようなデータを渡すかを定義できる場所が3つあると言及されている。

  • rendering pipeline への入力。これらの入力は application によって提供され、vertex shader に渡される(つまり application ステージから vertex ステージへのプロセス)。
  • vertex ステージの出力。これらの出力は vertex shader によって提供され、fragment shader に渡される(厳密には、ここで補間のステップがあるため、rasterization ステージに渡されると言うべきである)。
  • fragment shader への入力。vertex ステージの出力と fragment ステージの入力は同じ型であるが、実際には同じデータセットではない。なぜなら、rasterization ステージにおいて rasterizer は、補間の存在により、実際の vertex 数よりもはるかに多くの fragment function 入力型を生成するからである。

例えば、rendering pipeline への入力(CPU 側の application からのもの)には、vertex の位置データやカラーが含まれる可能性がある。

vertex shader 用のデータの準備

例えば、vertex の位置データとカラーは、SIMD ベクトル型を使用して構造体の中にラップすることができる。

Swifttypedef struct 
{
	vector_float2 position;
	vector_float4 color;
} AAPLVertex;

MSL では、SIMD 型が非常によく使われる。SIMD とは Single Instruction, Multiple Data の略であり、これらのベクトル型は並列計算を行うことができる。つまり、単一の命令で複数のデータ要素を同時に処理できるため、演算効率が向上する。通常のベクトル型と比較して、その違いは主に並列計算のパフォーマンス最適化の側面に現れる。SIMD ベクトル型は1つの命令で複数のデータ要素を並列処理できるため、大量のデータ処理シナリオにおいてパフォーマンスを大幅に向上させることができる。また、現代の GPU や CPU は通常、SIMD 命令セットに対するハードウェアサポートを備えており、ハードウェアアクセラレーションを最大限に活用できる。

参考資料:

  1. https://developer.apple.com/documentation/metal/mtlrenderpipelinedescriptor
  2. https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives